Counting by Hacking
4 minute read
Colour by Numbers is an art project set up near where I live in Telefonplan, Stockholm. It consists of two parts, a mobile app and a set of LED lights installed on a 72 meter tall tower. The app allows users to "color" the building by sending commands to the LED lights, which then light up in the corresponding windows in the building.


The app is quite simple. You first aquire a global lock through the app, which allows you to control the building freely for 5 minutes, all by yourself. During this time, you can send commands to the building to light up the different windows in specific colors. All colors need to be set through the apps interface, which although convenient for users, is limiting for more complex designs. I was curious about how the app worked, and if there was a way to automate the process of coloring the building, so I decided to reverse engineer the app and create a script for automatic coloring.
Tools Used
- Python: Primary programming language.
- Fiddler: For intercepting encrypted network traffic.
Method
Inspecting the Network Traffic
I first needed to intercept the network traffic between the app and the server. To do this you usually need to follow one of the two approaches below:
- Load the app on a regular device connected to a debug proxy like Fiddler, and see the network trafic directly. Only works for simple apps without SSL pinning and network obfuscation.
- Load the app on an emulator, and use a tool like Frida to bypass SSL pinning with a custom script. This has worked in every single case I have tested, but often requires you to write a custom script for each app, which is annoying and might break with updates.
Luckily, the Colour by Numbers app did not have any SSL pinning or obfuscation (in fact it did not even use HTTPS at all) so I was able capture trafic right away! The app used a simple REST API that was relatively simple to map out:
.../cbn-live/requestLock: Request the global lock for 5 minutes..../cbn-live/getColours: Get a list of RGB values for all windows in the building..../cbn-live/setColour: Set the colors of all windows in the building (takes in serialized dict)..../cbn-live/releaseLock: Release the global lock..../cbn-live/touchLock: Get the current status of a global lock (when it expires).
Great! Now we have everything we need to automate the coloring process. Just throw everything into python with requests and call it a day. But wait, why settle for updating the colors for just 5 minutes when you can controll the tower all the time? What if I could take over the tower and keep it for myself indefinitely? I just needed to find a way to bypass the lock mechanism.
Insecure Lock Mechanism
Normally the lock mechanism works like this:
- App internally runs an asyncronous loop that checks the status of the lock every second by calling
touchLock. - If the lock is expired, the "Start" button lights up in the app
- When the user clicks "Start", the app calls
requestLockto acquire the lock for 5 minutes, and starts sending color updates to the server.
This is inherently a flawed design. You are completely at the mercy of the random 1s interval, and response time of the touchLock endpoint. If you were to call requestLock directly instead, you could acquire the lock faster than possible even theoretically for regular users mashing the "Start" button.
Another thing to note is that there is no authentication at all. The server has no way of knowing who is sending the requests, and doesn't care if a user gets the lock for the first time or the hundredth. This means that if you were to call requestLock in a loop, you could easily acquire the lock indefinitely, and keep the building colored for arbitrary periods.
# The API endpoints discovered via Fiddler
API_URL = "http://api.colourbynumbers.org/cbn-live"
RETRY_INTERVAL = 0.01 # 10ms: Faster than a human tapping a button
def hijack_tower():
# Aggressively spam the lock request.
print("Waiting for tower to become available...")
while True:
response = requests.get(f"{API_URL}/requestLock").json()
if response['status']['code'] == 1:
session_hash = response['hash']
print(f"Lock acquired! Session: {session_hash}")
break
time.sleep(RETRY_INTERVAL)
if __name__ == "__main__":
hijack_tower()
Here is a PoC of the lock hijack script that makes it impossible for anyone else to acquire the lock, as it is being requested every 10ms.
Coloring the Tower
Now that we have control of the tower, we can color it however we want! In the app you can really only modify one color at a time, but through the API I found that no such limit exists. So what can you do with this?
Morse Code
Because the tower is large enough to be visible from far away, you can use it to communicate messages far away if sent in a format that is easy to decode from a distance. Normally this would be using letters, but as we only have a 1x7 matrix to work with (7 windows high) we have to get creative. I considered using a binary encoding, but settled for morse code in the end, as it is probably the most well-known encoding for this kind of thing. By using short flashes of light for dots and long flashes for dashes, I was able to send arbitrary messages to anyone with a visual of it. According to the website "[t]he light installation is widely visible throughout Stockholm".
Transitions, Animations, Art
Despite controlling the tower it isn't trivial to create complex designs with it. Think through this. How would you create interesting visuals for a 1x7 grid of lights?
Interestingly this is the part of the project that took the most time. I created a custom "video library" for the tower, with features like color transitions (smoothly going from all blue to all red for example), frame transitions (linearly interpolating between two frames), and a few animations such as slide up/down between two images and lightening/darkening of a frame.
class AnimationEngine:
@staticmethod
def interpolate_color(color1, color2, weight):
"""Linearly interpolate between two RGB colors."""
return [int(c1 + (c2 - c1) * weight) for c1, c2 in zip(color1, color2)]
@staticmethod
def merge_frames(frame1, frame2, weight):
"""Blends two full tower states (7 windows) together."""
return [interpolate_color(c1, c2, weight) for c1, c2 in zip(frame1, frame2)]
@staticmethod
def transition(start_frame, end_frame, steps):
"""Creates a smooth fade transition between two states."""
...
@staticmethod
def slide_up(current_frame, new_frame, steps):
"""Slides a new color pattern up the tower from the bottom."""
...
@staticmethod
def resize(frame, target_size):
"""
Scales a pattern (like a 10-pixel flag) to fit the
physical constraints of the tower (7 windows) using
nearest neighbor interpolation.
"""
...
@staticmethod
def pause(frame, steps):
"""Holds a single color state for a specific duration."""
...
# Example Usage:
blue_tower = [[0, 0, 255]] * 7
yellow_tower = [[255, 255, 0]] * 7
# Generate a 30-frame smooth fade from blue to yellow
animation = AnimationEngine.transition(blue_tower, yellow_tower, steps=30)
Here is an outline of the animation engine I made. The only thing remaining was to figure out what I wanted to show. It needed to be something that can be easily visualized with just 7 pixels, created an interesting pattern when animated, accepts slowness in updates (the lights react slowly to abrupt changes) and ideally had some kind of meaning behind it.
Despite not being politically involved in any way, I chose to show ukraineian propaganda. In some sense it made sense:
- Both the ukraineian and Russian flags are easily to visualize
- The war had just started at the time, and ukraineian support was uncontroversial
- I could visually show the ukraineian flag being "attacked" by the Russian flag, and then recovering again
- I could incorporate the rainbow as a symbol for anti russian visualization (a rainbow looks great on the tower)
- I could send a message in morse code saying "Free ukrainee"
In hindsight this was a missed opportunity to create a cool cicada 3301 level scavenger hunt, and in general I don't like this kind of political messaging. It just happened to fit really well with the constraints of the project. Anyways I'm quite happy with how it turned out:
Below is the full animation code:
LIGHT_CODE = Animation.morse_code("Free ukraine") + \
Animation.merge(60, Color.black, Flag.ukraine) + \
Animation.pause(5, Flag.ukraine) + \
Animation.blink(4, 0, Flag.ukraine, Image.lighten(0.4, Flag.ukraine)) + \
Animation.pause(3, Flag.ukraine) + \
Animation.blink(4, 0, Flag.ukraine, Image.lighten(0.4, Flag.ukraine)) + \
Animation.pause(3, Flag.ukraine) + \
Animation.blink(4, 0, Flag.ukraine, Image.lighten(0.4, Flag.ukraine)) + \
Animation.pause(3, Flag.ukraine) + \
Animation.blink(4, 0, Flag.ukraine, Image.lighten(0.4, Flag.ukraine)) + \
Animation.pause(3, Flag.ukraine) + \
Animation.blink(4, 0, Flag.ukraine, Image.lighten(0.4, Flag.ukraine)) + \
Animation.pause(5, Flag.ukraine) + \
Animation.merge(1, Flag.ukraine, (Flag.russian[::-1] + Image.resize(9, Flag.ukraine[::-1], 1))[-10:][::-1]) + \
Animation.merge(1, (Flag.russian[::-1] + Image.resize(9, Flag.ukraine[::-1], 1))[-10:][::-1], (Flag.russian[::-1] + Image.resize(8, Flag.ukraine[::-1], 1))[-10:][::-1]) + \
Animation.merge(1, (Flag.russian[::-1] + Image.resize(8, Flag.ukraine[::-1], 1))[-10:][::-1], (Flag.russian[::-1] + Image.resize(7, Flag.ukraine[::-1], 1))[-10:][::-1]) + \
Animation.merge(2, (Flag.russian[::-1] + Image.resize(7, Flag.ukraine[::-1], 1))[-10:][::-1], (Flag.russian[::-1] + Image.resize(6, Flag.ukraine[::-1], 1))[-10:][::-1]) + \
Animation.merge(2, (Flag.russian[::-1] + Image.resize(6, Flag.ukraine[::-1], 1))[-10:][::-1], (Flag.russian[::-1] + Image.resize(5, Flag.ukraine[::-1], 1))[-10:][::-1]) + \
Animation.merge(1, (Flag.russian[::-1] + Image.resize(6, Flag.ukraine[::-1], 1))[-10:][::-1], (Flag.russian[::-1] + Image.resize(6, Flag.ukraine[::-1], 1))[-10:][::-1]) + \
Animation.merge(1, (Flag.russian[::-1] + Image.resize(6, Flag.ukraine[::-1], 1))[-10:][::-1], (Flag.russian[::-1] + Image.resize(7, Flag.ukraine[::-1], 1))[-10:][::-1]) + \
Animation.merge(2, (Flag.russian[::-1] + Image.resize(7, Flag.ukraine[::-1], 1))[-10:][::-1], (Flag.russian[::-1] + Image.resize(8, Flag.ukraine[::-1], 1))[-10:][::-1]) + \
Animation.merge(3, (Flag.russian[::-1] + Image.resize(7, Flag.ukraine[::-1], 1))[-10:][::-1], (Flag.russian[::-1] + Image.resize(6, Flag.ukraine[::-1], 1))[-10:][::-1]) + \
Animation.merge(2, (Flag.russian[::-1] + Image.resize(6, Flag.ukraine[::-1], 1))[-10:][::-1], (Flag.russian[::-1] + Image.resize(5, Flag.ukraine[::-1], 1))[-10:][::-1]) + \
Animation.merge(1, (Flag.russian[::-1] + Image.resize(5, Flag.ukraine[::-1], 1))[-10:][::-1], (Flag.russian[::-1] + Image.resize(4, Flag.ukraine[::-1], 1))[-10:][::-1]) + \
Animation.merge(1, (Flag.russian[::-1] + Image.resize(4, Flag.ukraine[::-1], 1))[-10:][::-1], (Flag.russian[::-1] + Image.resize(3, Flag.ukraine[::-1], 1))[-10:][::-1]) + \
Animation.merge(1, (Flag.russian[::-1] + Image.resize(3, Flag.ukraine[::-1], 1))[-10:][::-1], (Flag.russian[::-1] + Image.resize(2, Flag.ukraine[::-1], 1))[-10:][::-1]) + \
Animation.merge(1, (Flag.russian[::-1] + Image.resize(2, Flag.ukraine[::-1], 1))[-10:][::-1], (Flag.russian[::-1] + Image.resize(1, Flag.ukraine[::-1], 1))[-10:][::-1]) + \
Animation.merge(1, (Flag.russian[::-1] + Image.resize(1, Flag.ukraine[::-1], 1))[-10:][::-1], (Flag.russian[::-1])[-10:][::-1]) + \
Animation.pause(5, Flag.russian) + \
Animation.blink(6, 3, Flag.russian, Image.merge_colors(0.6, Flag.russian, Color.red)) + \
Animation.blink(6, 3, Flag.russian, Image.merge_colors(0.6, Flag.russian, Color.red)) + \
Animation.blink(6, 3, Flag.russian, Image.merge_colors(0.6, Flag.russian, Color.red)) + \
Animation.blink(6, 3, Flag.russian, Image.merge_colors(0.6, Flag.russian, Color.red)) + \
Animation.merge(3, Flag.russian, Image.merge_colors(0.6, Flag.russian,
Image.single_color(Flag.pride[0]))) + \
ComplexAnimation.pride_flag_merge(20, 0.6, Flag.russian) + \
Animation.merge(5, Image.merge_colors(0.6, Flag.russian,
Image.single_color(Flag.pride[0])), Flag.russian) + \
Animation.pause(3, Flag.russian) + \
Animation.bottom_up(8, Flag.russian, Flag.pride) + \
Animation.bottom_up(8, Flag.pride, Flag.pride) + \
Animation.bottom_up(8, Flag.pride, Flag.pride) + \
Animation.bottom_up(8, Flag.pride, Flag.pride) + \
Animation.bottom_up(8, Flag.pride, Flag.pride) + \
Animation.bottom_up(8, Flag.pride, Flag.pride) + \
Animation.bottom_up(8, Flag.pride, Flag.pride) + \
Animation.bottom_up(8, Flag.pride, Flag.pride) + \
Animation.bottom_up(8, Flag.pride, Flag.pride) + \
Animation.bottom_up(8, Flag.pride, Flag.pride) + \
Animation.bottom_up(8, Flag.pride, Flag.pride) + \
Animation.bottom_up(8, Flag.pride, Flag.pride) + \
Animation.bottom_up(8, Flag.pride, Flag.pride) + \
Animation.bottom_up(8, Flag.pride, Flag.ukraine) + \
Animation.pause(10, Flag.ukraine) + \
Animation.pause(5, Flag.ukraine) + \
Animation.top_down(8, Flag.ukraine, Flag.ukraine) + \
Animation.top_down(8, Flag.ukraine, Flag.ukraine) + \
Animation.top_down(8, Flag.ukraine, Flag.ukraine) + \
Animation.top_down(8, Flag.ukraine, Flag.ukraine) + \
Animation.pause(5, Flag.ukraine) + \
Animation.merge(30, Flag.ukraine, Color.black)
Blocked!
After running it for a while, I noticed that the tower started sending error responses when trying to aquire locks. Turned out I had been IP blocked on my server, likely when they realized the lock was being highjacked (or just a notification about abnormal network activity).
There are a few ways to get around this, but the simplest is usually (unless geoblocked) to route the requests through TOR. I would be able to dynamically change the exit node to get a new IP address whenever I needed a new lock. Obviously TOR nodes are public, but in my experience this rarely leads to blocks.
They had a good point though: A single entity shouldn't control the tower all the time. I made some changes to the script such that it would only run during the night (when less people use the tower) and would leave a 10s period after each lock release where the tower would be open for anyone to use. This way it would become more of a fallback "screensaver" that was active only when noone else needed it.
Result
I followed local news for a while in case someone would report on the tower sending a repeating message, or someone publically decoding the morse code. Unfortunately noone seemed to have noticed, despite leaving it up for a few months :(
One other takeaway I was surprised by was the "video library". It was really fun to work on, and was quite hard to get right. The debugging step literally required me to push the script to my server, bike for 15 minutes to the tower, and see how the tower had reacted to the changes hahah.