문제: DreamHack 워게임 — Nonsense 분류: reversing 난이도: 🥇 Gold 1 FLAG:
GoN{wait_i_can_handle_my_fault_plz_don_t_hit_me}
문제 설명이 두 줄이다. "이 바이너리의 루틴은 말이 안 된다", "리버싱하지 말고 그냥 출제자를 두들겨 패자." 그 아래에는 야구방망이를 든 참가자가 출제자를 쫓는 짤이 붙어 있다.

2022 Spring GoN Open Qual CTF 출제작이고, 지금까지 122명이 풀었다. 설명이 농담처럼 보이지만 실제로 이 바이너리의 "루틴"은 정말로 말이 안 되게 생겼다. 정확히는, 말이 되는 부분이 우리가 보고 있는 코드 바깥에 있다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | Nonsense |
| 난이도 | 🥇 Gold 1 |
| 분류 | reversing |
| 제공 파일 | nonsense (ELF 64-bit PIE, stripped, 26KB) |
| 출제 | 2022 Spring GoN Open Qual CTF |
| 핵심 기법 | SIGSEGV 핸들러를 제어흐름으로 쓰는 난독화 + 2바이트 스펀지 해시 전수조사 |
흐름은 이렇다. 48글자 인자를 2바이트씩 24조각으로 자르고, 조각마다 스펀지 해시를 돌려 32비트 값을 만든다.
그 값을 포인터로 역참조하니 프로그램은 매번 죽는다. 죽는 게 정상이다.
.init_array 가 미리 걸어 둔 SIGSEGV 핸들러가 폴트 주소를 정답표와 맞춰 보고, 맞으면 카운터를 올린 뒤
레지스터를 고쳐 그 자리에서 재개시킨다. 판정은 main 이 아니라 .fini_array 가 한다.
🧩 배경 — 시그널 핸들러가 어떻게 제어흐름이 되나
리눅스에서 sigaction 에 SA_SIGINFO 를 주면 핸들러가 인자를 셋 받는다.
void handler(int signo, siginfo_t *info, void *ucontext);여기서 셋째 인자가 핵심이다. ucontext_t 안의 uc_mcontext.gregs[] 는 시그널이 발생한 순간의
레지스터 사본이고, 커널은 핸들러가 리턴할 때 이 사본을 그대로 되돌려 놓고 실행을 재개한다.
즉 핸들러 안에서 gregs[REG_RIP] 를 고치면 복귀 지점이 바뀐다. gregs[REG_RAX] 를 고치면
복귀 직후의 RAX 값이 바뀐다. x86-64 리눅스의 gregs 인덱스는 REG_RAX 가 13, REG_RIP 가 16이라
바이트 오프셋으로는 각각 0x68 과 0x80 이다. 이 두 숫자는 뒤에서 디스어셈블리에 그대로 나온다.
siginfo_t 의 si_addr(오프셋 0x10)은 접근하려다 실패한 주소다.
SIGSEGV 라면 죽게 만든 그 포인터 값이 들어 있다.
정리하면 프로그램은 이런 통신 채널을 하나 갖는 셈이다.
- 보내는 쪽: 아무 주소나 역참조한다 → 그 주소가
si_addr로 핸들러에 전달된다 - 받는 쪽: 핸들러가 값을 확인하고,
gregs를 고쳐 아무 일도 없었던 것처럼 되돌린다
정상 코드에서는 잘 안 쓰는 구조인데, 난독화 관점에서는 꽤 고약하다. 디스어셈블리만 위에서 아래로 읽으면 데이터가 어디로 흘러가는지 보이지 않기 때문이다.
🔬 정찰 — 입력 통로가 안 보인다
먼저 파일 하나뿐인 배포본을 훑는다.
file extracted/nonsense; readelf -hW extracted/nonsense | grep -E "Type:|Entry"; nm -D extracted/nonsense
스트립된 64비트 PIE. 임포트 목록이 특이하다.
strlen · malloc · free · memset · printf · putchar · exit · _exit · sigaction.
입력 함수가 하나도 없다. read 도 scanf 도 fgets 도 없다.
strlen 이 있으니 문자열은 받는데, 표준입력으로 받는 게 아니다. 그러면 남는 건 argv 다.
그리고 sigaction. 리버싱 문제에서 이게 보이면 대개 안티디버깅이거나 시그널 기반 난독화다.
실제로 돌려 보면 아무 말이 없다.
./extracted/nonsense; echo "rc=$?"; ./extracted/nonsense AAAA; echo "rc=$?"; ./extracted/nonsense AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA; echo "rc=$?"
인자가 없거나 짧으면 조용히 _exit(1). 48글자를 주면 그제서야 wrong... 이 나온다.
길이 조건이 48이라는 건 여기서 이미 드러난다.
.rodata 에는 문자열이 셋뿐이다 — correct!! · wrong... · %02x.
방금 본 건 둘째뿐이고, 셋째는 아직 어디에도 안 쓰였다.
🧭 main 을 읽는다 — 그리고 한 번 잘못 읽는다
엔트리에서 __libc_start_main 에 넘기는 주소를 따라가면 main 은 0xb0a 다.
objdump -d -M intel --start-address=0xb0a --stop-address=0xb97 extracted/nonsense | sed -f annot_main.sed | tail -n +6주석은 objdump 출력에 내가 sed 로 얹은 것이다. 치환 규칙은 문제 폴더의 annot_main.sed 에 있다.
/^ *b21:/s|$| <-- argc == 2 (인자 하나)|
/^ *b3f:/s|$| <-- strlen(argv[1])|
/^ *b44:/s|$| <-- 길이는 정확히 48|
/^ *b72:/s|$| <-- argv[1] 을 2바이트(uint16) 단위로|
/^ *b7a:/s|$| <-- 스펀지 해시. uint32 를 돌려준다|
/^ *b7f:/s|$| <-- ★ 그 32비트를 포인터로 역참조 = SIGSEGV|
/^ *b82:/s|$| <-- 누적합|
/^ *b8a:/s|$| <-- 청크 24개 반복|
/^ *b90:/s|$| <-- 합이 0 이어야 'wrong...' 을 건너뛴다|
C 로 옮기면 이렇게 짧다.
int main(int argc, char **argv) {
long sum = 0;
if (argc != 2) _exit(1);
if (strlen(argv[1]) != 0x30) _exit(1);
for (int i = 0; i <= 0x17; i++) {
unsigned short v = *(unsigned short *)(argv[1] + 2 * i);
long *p = (long *)(unsigned long)sub_AB7(v); /* 32비트 반환 */
sum += *p; /* 0xb7f */
}
if (sum != 0) printf("wrong...");
return 0;
}sub_AB7 은 uint32_t 를 돌려준다. 그걸 포인터로 쓴다. 64비트 프로세스에서 4바이트짜리 주소는
매핑돼 있을 리가 없으니, 0xb7f 의 mov rax, [rax] 는 매 반복마다 반드시 죽는다.
▶🐛 삽질 — main 만 읽고 '합을 0으로 만드는 문제'라고 결론냈다
처음엔 main 이 전부인 줄 알았다. 그러면 목표는 "24개 청크의 해시가 가리키는 곳에서 읽은 값의 합이 0"이다.
그런데 그게 가능하려면 해시 결과가 매핑된 주소여야 하고, 그 주소의 8바이트가 0이어야 한다.
32비트 값이 가리킬 수 있는 곳은 4GB 아래인데 PIE 바이너리는 그 근처에 아무것도 안 올린다.
합을 0으로 만드는 조합을 찾는 문제라고 보면 애초에 해가 없다.
여기서 한 번 멈췄다. 그런데 걸리는 게 둘 있었다.
첫째, .rodata 의 correct!! 를 main 이 안 쓴다. 성공 문자열이 있는데 성공 경로가 없다.
둘째, 임포트에 sigaction 이 있는데 main 어디에서도 안 부른다.
두 개가 같은 곳을 가리켰다 — main 밖에서 도는 코드가 있다.
💣 핵심 — .init_array 가 걸어 둔 SIGSEGV 핸들러
main 밖에서 도는 코드는 생성자와 소멸자다. 동적 섹션을 보면 둘 다 항목이 두 개씩 있고,
PIE 라 실제 주소는 R_X86_64_RELATIVE 재배치의 addend 에 적혀 있다.
readelf -dW extracted/nonsense | grep -E "INIT_ARRAY|FINI_ARRAY"; readelf -rW extracted/nonsense | grep RELATIVE; objdump -d -M intel --start-address=0x9f9 --stop-address=0xa5a extracted/nonsense | sed -f annot_ctor.sed | tail -n +6/^ *a2c:/s|$| <-- 핸들러 = 0x92a|
/^ *a3a:/s|$| <-- sa_flags = 4 = SA_SIGINFO (si_addr·ucontext 를 받는다)|
/^ *a50:/s|$| <-- signum 11 = SIGSEGV|
/^ *a55:/s|$| <-- sigaction 등록|
.init_array 는 0x920(컴파일러가 만든 frame_dummy)과 0x9f9,
.fini_array 는 0x8e0(등록 해제 루틴)과 0xa7f 다. 우리가 볼 건 0x9f9 와 0xa7f.
0x9f9 는 sigaction(11, &act, NULL) 하나만 한다. act.sa_flags = 4, 즉 SA_SIGINFO.
핸들러는 0x92a. main 이 시작하기도 전에 SIGSEGV 가 예약돼 있었다.
그리고 0xa7f 는 이렇다.
void fini(void) { /* .fini_array[1] */
if (*(long *)0x206100 == 0x18) printf("correct!!");
else printf("wrong...");
}0x206100 은 .bss 다. 그 값이 24면 correct!!.
main 이 리턴한 뒤에 도는 코드가 진짜 판정을 한다.
핸들러가 하는 일
objdump -d -M intel --start-address=0x92a --stop-address=0x9f9 extracted/nonsense | sed -f annot_handler.sed | tail -n +6/^ *939:/s|$| <-- ucontext + 0x28 = uc_mcontext|
/^ *949:/s|$| <-- gregs[REG_RIP] (0x80 = 16*8)|
/^ *958:/s|$| <-- siginfo->si_addr (오프셋 0x10)|
/^ *960:/s|$| <-- 11 = SIGSEGV 만 처리|
/^ *96a:/s|$| <-- main 주소|
/^ *971:/s|$| <-- +0x75 = 0xb7f, 그 역참조에서 난 폴트만|
/^ *97f:/s|$| <-- gregs[REG_RAX] = 0 (0x68 = 13*8)|
/^ *992:/s|$| <-- RIP += 3, 폴트 명령을 건너뛴다|
/^ *9b0:/s|$| <-- .bss 0x206100 = 지금까지 맞춘 개수|
/^ *9c9:/s|$| <-- 정답표 .data 0x206020|
/^ *9d4:/s|$| <-- si_addr == 표[counter] 인가|
/^ *9e5:/s|$| <-- 맞으면 counter++|
/^ *9f0:/s|$| <-- 표는 최대 48개까지 훑는다|
의사코드로 정리하면 이렇다.
void handler(int sig, siginfo_t *info, void *uctx) {
mcontext_t *mc = (mcontext_t *)((char *)uctx + 0x28);
void *fault_rip = (void *)mc->gregs[REG_RIP]; /* +0x80 */
void *si_addr = info->si_addr; /* +0x10 */
if (sig != SIGSEGV) return;
if (fault_rip != (char*)main + 0x75) return; /* 0xb7f 에서 난 폴트만 */
mc->gregs[REG_RAX] = 0; /* +0x68 */
mc->gregs[REG_RIP] += 3; /* mov rax,[rax] 는 3바이트 */
for (int i = 0; i <= 0x2f; i++)
if (i == counter && si_addr == table[i]) /* table = .data 0x206020 */
counter++;
}세 가지가 한 번에 설명된다.
RAX 를 0으로 덮으니 main 의 sum 은 영원히 0이다. wrong... 이 안 찍힌 건 우리가 잘해서가 아니라
핸들러가 그렇게 만들어 놨기 때문이다. RIP 를 3 더하니 죽은 명령을 건너뛰고 루프가 계속 돈다.
그리고 si_addr — 우리가 만든 해시값 — 이 정답표의 counter 번째 항목과 같을 때만 counter 가 오른다.

즉 이 문제의 정답은 "폴트 주소 24개의 수열"이다. 값 자체를 어디에도 저장하지 않고, CPU 예외로만 전달한다.
정답표를 꺼낸다
핸들러가 참조하는 .data 0x206020 을 ELF 파일에서 직접 읽는다.
#!/usr/bin/env python3
"""ELF 의 .data 를 직접 열어 SIGSEGV 핸들러가 참조하는 정답표를 뽑는다.
핸들러(0x92a)는 si_addr 을 `0x206020 + i*8` 과 비교한다. 8바이트로 저장돼 있지만
상위 4바이트는 전부 0 — sub_AB7 의 반환이 32비트라 그렇다.
바로 뒤 0x2060e0 의 17바이트는 스펀지 해시의 IV 다.
"""
import struct
BIN = "extracted/nonsense"
DATA_VADDR, DATA_OFF = 0x206000, 0x6000 # readelf -S 의 .data Address / Off
raw = open(BIN, "rb").read()
off = lambda va: DATA_OFF + (va - DATA_VADDR)
print("[표] .data 0x206020 — 청크별 si_addr 정답 24개")
tbl = [struct.unpack_from("<Q", raw, off(0x206020) + 8 * i)[0] for i in range(24)]
for i in range(0, 24, 4):
print(" " + " ".join(f"[{i+j:2}] {tbl[i+j]:#018x}" for j in range(4)))
dup = {v: [i for i, t in enumerate(tbl) if t == v] for v in set(tbl)}
print("\n[중복] 같은 값이 두 번 나온 인덱스 =",
sorted(v for v in dup.values() if len(v) > 1))
iv = raw[off(0x2060e0):off(0x2060e0) + 17]
print(f"\n[IV] .data 0x2060e0 — 17바이트({len(iv) * 8}비트) 스펀지 초기상태")
print(" " + iv.hex(" "))python3 dump_targets.py
값이 겹치는 인덱스가 셋 있다 — [3]과 [21], [6]과 [8], [7]과 [20].
해시가 위치와 무관하다는 뜻이고, 같은 두 글자가 그 자리에 들어간다는 뜻이기도 하다.
평문 24조각 중 3쌍이 겹치는 건 영어 단어가 섞인 문자열이라면 자연스럽다.
표 바로 뒤 0x2060e0 에는 17바이트가 더 있다. 이게 뭔지는 해시를 볼 때 드러난다.
🧪 런타임으로 확인 — 정말 그 자리에서 죽나
정적 분석만으로 결론내지 않고 gdb 로 한 번 확인한다. 스트립된 PIE 라 심볼이 없지만,
로드 베이스는 페이지 정렬이므로 폴트 지점의 RIP 에서 0xb7f 를 빼면 베이스가 나온다.
# gdb_fault.gdb — 첫 번째 SIGSEGV 를 잡아 "어디서 · 무엇을" 역참조하다 죽었는지 본다.
set pagination off
set confirm off
run
set $base = $rip - 0xb7f
printf "\n[RIP] %p (PIE base %p + 0xb7f = main+0x75)\n", $rip, $base
printf "[RAX] 0x%x <- sub_AB7 이 돌려준 32비트 값\n", $rax
printf "[si_addr] 0x%x <- .data 0x206020 표의 0번 항목과 같아야 한다\n", $_siginfo._sifields._sigfault.si_addr
printf "[표 [0]] 0x%x\n", *(unsigned long*)($base + 0x206020)
set disassembly-flavor intel
x/1i $rip
quitgdb -q -batch -x gdb_fault.gdb --args extracted/nonsense AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
RIP 는 정확히 베이스 + 0xb7f, RAX 와 si_addr 은 둘 다 0x272fcba1.
정답표 0번은 0xab1ff171 이라 다르다 — AA 는 첫 조각이 아니라는 뜻이다.
폴트를 내는 명령이 mov rax, QWORD PTR [rax] 라는 것도 화면에 그대로 찍힌다.
여기까지 오면 문제는 아주 단순해진다.
sub_AB7(2바이트) == table[i]를 만족하는 2바이트를i = 0..23각각에 대해 찾아라.
🧩 해시는 뭐였나 — 레이트 8비트 스펀지
sub_AB7(0xab7)은 스택에 4바이트 버퍼를 두고 0x57e3 을 부른 뒤 그 앞 4바이트를 돌려준다.
0x57e3 이 진짜 해시다.
objdump -d -M intel --start-address=0x57e3 --stop-address=0x5877 extracted/nonsense | sed -f annot_sponge.sed | tail -n +6/^ *57e7:/s|$| <-- 스펀지 상태 0x230 바이트를 스택에|
/^ *581c:/s|$| <-- init : IV 17바이트를 비트 136개로 펼친다|
/^ *583e:/s|$| <-- absorb: 입력 2바이트를 한 바이트씩 흡수|
/^ *5857:/s|$| <-- squeeze: 17바이트를 짜낸다|
세 함수가 순서대로 불린다. 각각을 읽어 보면 전형적인 스펀지 구조다.
| 함수 | 하는 일 |
|---|---|
0x556d init | .data 0x2060e0 의 17바이트를 비트 136개로 펼쳐 상태에 넣는다 (MSB 먼저) |
0x55eb absorb | 입력 한 바이트의 비트 8개를 상태 [0x80..0x87] 에 XOR 하고 permutation 을 1회 |
0x56c5 squeeze | 패딩 비트 하나를 뒤집고 permutation, 그 뒤 8비트씩 17번 꺼낸다 |
상태가 136비트, 그중 레이트(입출력에 노출되는 부분)가 8비트, 용량이 128비트.
비트 하나를 int 하나에 담아 놓아서 상태 구조체가 560바이트나 된다. 비트슬라이스 구현 그대로다.
여기서 .data 0x2060e0 의 정체가 밝혀진다 — 17바이트 = 136비트 = 스펀지 초기상태(IV) 다.
정답표 바로 뒤에 붙어 있어서 처음엔 표의 일부처럼 보였다.
permutation 은 0xc51 하나다.
objdump -d -M intel --start-address=0xc51 --stop-address=0xc8e extracted/nonsense | sed -f annot_perm.sed | tail -n +6; objdump -d -M intel --start-address=0x1d4a --stop-address=0x1d5b extracted/nonsense | sed -f annot_perm.sed | tail -n +6/^ *c5d:/s|$| <-- 612개짜리 int 버퍼 A|
/^ *c6b:/s|$| <-- 612개짜리 int 버퍼 B|
/^ *c79:/s|$| <-- 554개짜리 int 버퍼 C|
/^ *d1f:/s|$| <-- C\[0..9\] = -1 로 초기화|
/^ *1d4a:/s|$| <-- 라운드 카운터 ++|
/^ *1d4e:/s|$| <-- 0x21f = 543, 즉 544 라운드|
malloc 세 번으로 612 · 612 · 554 짜리 int 배열을 잡고, 상태 136비트를 68비트씩 두 덩이로 나눠
앞쪽에 싣는다. 그다음 544라운드를 돌면서 매 라운드마다 세 배열의 다음 칸을 계산해 채운다.
계산식은 XOR 로 이어진 탭 여러 개에 AND 항이 몇 개 섞인 형태 — 시프트 레지스터 세 개를
맞물린 비선형 되먹임(NLFSR)이고, 구조상 Trivium 계열에 가깝다.
마지막에 각 배열의 뒤쪽 68칸을 도로 상태에 써 넣는다.
이걸 파이썬으로 옮기는 건 가능은 하지만, 탭 인덱스를 하나만 잘못 읽어도 조용히 틀린 답이 나온다. 그래서 재구현하지 않기로 했다. 오라클로 쓸 진짜 구현이 이미 손에 있다.
▶🐛 삽질 — 4KB짜리 함수 넷 중 셋은 아무도 안 부른다
0xc51 다음에도 비슷하게 생긴 대형 함수가 셋 더 있다. 0x1e01, 0x3006, 0x4227.
전부 malloc 세 번으로 시작해 긴 XOR/AND 체인이 이어지는, 눈으로는 구별이 안 되는 코드다.
"라운드마다 다른 permutation 을 쓰나?" 싶어서 한동안 세 개를 나란히 놓고 비교했다.
그러다 참조를 세 봤다.
#!/usr/bin/env bash
# .text 안의 대형 함수 4개 중 실제로 호출되는 건 어느 것인가.
# objdump 전체를 훑어 각 주소로 향하는 call/jmp/lea 참조를 센다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
D=full_disasm.txt
[ -f "$D" ] || objdump -d -M intel extracted/nonsense > "$D"
printf '%-10s %-8s %-10s %s\n' 주소 크기 참조수 판정
for pair in c51:1e00 1e01:3005 3006:4226 4227:5551; do
a=${pair%%:*}; b=${pair##*:}
size=$(( 0x$b - 0x$a + 1 ))
refs=$(grep -cE "(call|jmp|lea) +.*\b$a\b" "$D" || true)
if [ "$refs" -eq 0 ]; then v="죽은 코드 (아무도 안 부름)"; else v="살아 있음"; fi
printf '0x%-8s %-8s %-10s %s\n' "$a" "$size" "$refs" "$v"
done./find_dead.sh
셋 다 참조가 0이다. .text 가 20,690바이트인데 그중 14,161바이트, 그러니까 68%가 아무도 안 부르는 코드다.
문제 이름값을 한다.
🎯 오라클 만들기 — 재구현 대신 바이너리를 직접 부른다
입력이 2바이트뿐이니 경우의 수는 65,536. 해시를 한 번 부르는 데 1ms 도 안 걸린다면 전수조사로 끝난다. 문제는 "해시를 어떻게 부르느냐"다.
PIE 실행파일은 ET_DYN 이라 구조상 공유 라이브러리와 같다. 그러니 dlopen 으로 열어
함수 포인터를 만들면 될 것 같은데, 해 보면 거부당한다.
▶🐛 삽질 — dlopen 이 PIE 실행파일을 거부한다
import ctypes
ctypes.CDLL("./extracted/nonsense")
# OSError: ... cannot dynamically load position-independent executableglibc 가 .dynamic 의 DT_FLAGS_1 에서 DF_1_PIE(0x08000000)를 보고 막는다.
readelf -d 에 Flags: NOW PIE 로 찍히는 그 비트다.
막는 이유는 있다 — PIE 실행파일은 자기가 프로세스의 주인이라고 가정하고 만들어졌으니 남의 프로세스에 얹으면 초기화가 꼬일 수 있다. 다만 이 바이너리는 생성자가 시그널 하나 거는 게 전부라 실제로 문제될 게 없다. 그래서 그 비트 하나만 끈 사본을 만들었다.
.dynamic 을 16바이트씩 순회하면서 태그가 0x6ffffffb(DT_FLAGS_1)인 항목을 찾아 비트를 지운다.
#!/usr/bin/env python3
"""nonsense 바이너리를 dlopen 가능한 .so 로 만든다.
PIE 실행파일은 ET_DYN 이라 구조상 공유 라이브러리와 같지만,
.dynamic 의 DT_FLAGS_1(0x6ffffffb) 에 DF_1_PIE(0x08000000) 가 켜져 있으면
glibc 의 dlopen 이 "cannot dynamically load position-independent executable"
로 거부한다. 그 비트 하나만 끄면 그대로 로드된다.
"""
import struct, shutil, sys
SRC = "extracted/nonsense"
DST = "nonsense_lib.so"
DYN_OFF, DYN_SIZE = 0x5d80, 0x1f0 # readelf -S 로 확인한 .dynamic
shutil.copy(SRC, DST)
buf = bytearray(open(DST, "rb").read())
patched = False
for off in range(DYN_OFF, DYN_OFF + DYN_SIZE, 16):
tag, val = struct.unpack_from("<QQ", buf, off)
if tag == 0x6ffffffb: # DT_FLAGS_1
print(f"DT_FLAGS_1 @ file+0x{off:x} = 0x{val:08x}")
val &= ~0x08000000 # DF_1_PIE 제거
struct.pack_into("<QQ", buf, off, tag, val)
print(f" -> 0x{val:08x}")
patched = True
if not patched:
sys.exit("DT_FLAGS_1 을 못 찾았다")
open(DST, "wb").write(buf)
print("WROTE", DST)사본을 열고 /proc/self/maps 에서 로드 베이스를 읽어 base + 0xab7 을 함수 포인터로 만든다.
#!/usr/bin/env python3
"""문제 바이너리의 해시 함수(0xab7)를 파이썬에서 직접 호출해 본다.
배포본 그대로는 dlopen 이 거부한다 — PIE 실행파일이라 DT_FLAGS_1 에 DF_1_PIE 가 켜져 있고
glibc 가 그걸 보고 막는다. mk_lib.py 가 그 비트만 끈 사본을 만들어 준다.
"""
import ctypes, os, subprocess, sys, time
HERE = os.path.dirname(os.path.abspath(__file__))
BIN = os.path.join(HERE, "extracted", "nonsense")
LIB = os.path.join(HERE, "nonsense_lib.so")
print("[1] 배포본을 그대로 dlopen 해 본다")
try:
ctypes.CDLL(BIN)
print(" -> 로드됨")
except OSError as e:
print(f" -> OSError: {e}")
print("\n[2] DF_1_PIE 비트를 끈 사본을 만든다")
subprocess.run([sys.executable, "mk_lib.py"], check=True, cwd=HERE)
print("\n[3] 사본을 dlopen 하고 base+0xab7 을 직접 부른다")
lib = ctypes.CDLL(LIB)
base = next(int(l.split("-")[0], 16) for l in open("/proc/self/maps")
if l.rstrip().endswith(os.path.basename(LIB)))
hash16 = ctypes.CFUNCTYPE(ctypes.c_uint32, ctypes.c_uint16)(base + 0xab7)
print(f" base = {base:#x}")
t0 = time.time()
for s in (b"Go", b"N{", b"AA"):
v = s[0] | (s[1] << 8) # main 이 읽는 그대로 리틀엔디언 uint16
print(f" hash({s.decode()!r} = {v:#06x}) = {hash16(v):#010x}")
print(f" {(time.time() - t0) / 3 * 1000:.2f} ms/call")python3 probe.py
한 번 호출에 0.92ms. 그리고 확인차 넣은 두 글자의 결과가 정답표와 맞아떨어진다 —
Go 가 0xab1ff171(표 0번), N{ 가 0x116437ce(표 1번). 오라클이 제대로 붙었다.
마지막 줄의 wrong... 은 파이썬이 끝나면서 라이브러리의 .fini_array 가 도는 소리다.
우리 프로세스 안에서 counter 가 0이니 당연히 그렇게 찍힌다. 이것 자체가 "진짜 그 바이너리를
로드했다"는 증거이기도 하다.
🚀 Full Exploit
플래그는 48글자 전부 출력 가능한 ASCII 일 테니 전수조사 범위를 0x20..0x7e 로 잡는다.
95 × 95 = 9,025 번이면 충분하다.
#!/usr/bin/env python3
"""Nonsense (DreamHack 455 / Gold 1, reversing) — solver
동작 요약
main 은 argv[1](48바이트)을 uint16 24개로 잘라 sub_AB7(chunk) 를 부른다.
sub_AB7 은 스펀지 해시(레이트 8비트·용량 128비트, 상태 136비트)를 돌려 17바이트를
뱉고 그 앞 4바이트를 uint32 로 돌려주는데, main 은 그 값을 '포인터'로 역참조한다.
→ 반드시 SIGSEGV. .init_array 가 걸어 둔 핸들러(0x92a)가 si_addr 을
.data 0x206020 의 정답표 24개와 순서대로 대조하고, 맞으면 카운터를 올린 뒤
RAX=0 / RIP+=3 으로 복구한다. .fini_array 의 0xa7f 가 카운터==24 면 correct!!.
즉 "argv[1] 의 2바이트 청크 24개를 해시한 값이 정답표와 순서대로 일치" 가 전부다.
해시는 2바이트 입력이라 경우의 수가 65,536 — 전수조사로 끝난다.
해시 자체를 재구현하지 않고 문제 바이너리를 그대로 오라클로 쓴다.
PIE 실행파일이라 dlopen 이 거부하므로 DT_FLAGS_1 의 DF_1_PIE 비트만 끈 사본을 만든다.
"""
import ctypes, os, struct, subprocess, sys, time
HERE = os.path.dirname(os.path.abspath(__file__))
BIN = os.path.join(HERE, "extracted", "nonsense")
LIB = os.path.join(HERE, "nonsense_lib.so")
# ── 1. .data 0x206020 의 정답표 24개를 ELF 에서 직접 읽는다 ─────────────
DATA_VADDR, DATA_OFF = 0x206000, 0x6000 # readelf -S
raw = open(BIN, "rb").read()
tbl_off = DATA_OFF + (0x206020 - DATA_VADDR)
TARGETS = [struct.unpack_from("<Q", raw, tbl_off + 8 * i)[0] for i in range(24)]
print("정답표(.data 0x206020):")
for i, t in enumerate(TARGETS):
print(f" [{i:2}] 0x{t:016x}")
# ── 2. dlopen 가능한 사본 준비 ──────────────────────────────────────────
if not os.path.exists(LIB):
subprocess.run([sys.executable, os.path.join(HERE, "mk_lib.py")], check=True, cwd=HERE)
lib = ctypes.CDLL(LIB)
base = next(int(l.split("-")[0], 16) for l in open("/proc/self/maps")
if l.rstrip().endswith(os.path.basename(LIB)))
hash16 = ctypes.CFUNCTYPE(ctypes.c_uint32, ctypes.c_uint16)(base + 0xab7)
print(f"\nlib base = 0x{base:x} / hash = base+0xab7")
# ── 3. 2바이트 전수조사 (출력 가능한 ASCII 조합만) ──────────────────────
want = {t: i for i, t in enumerate(TARGETS)}
found = {}
t0 = time.time()
for hi in range(0x20, 0x7f):
for lo in range(0x20, 0x7f):
v = hash16(lo | (hi << 8)) # 리틀엔디언: lo 가 앞 글자
if v in want:
found.setdefault(v, bytes((lo, hi)))
print(f"전수조사 {(0x7f-0x20)**2}개 / {time.time()-t0:.1f}s / 적중 {len(found)}/24")
missing = [i for t, i in want.items() if t not in found]
if missing:
sys.exit(f"인덱스 {missing} 미발견 — 출력 가능 범위 밖")
flag = b"".join(found[t] for t in TARGETS)
print("\nFLAG:", flag.decode())
print("len :", len(flag))
# ── 4. 문제 바이너리로 직접 확인 ────────────────────────────────────────
out = subprocess.run([BIN, flag], capture_output=True, text=True)
print("검증:", repr(out.stdout), "rc =", out.returncode)hi 와 lo 의 순서에 주의해야 한다. main 은 movzx eax, WORD PTR [rax] 로 읽으니
리틀엔디언이다. 앞 글자가 하위 바이트다. 여기를 뒤집으면 24조각 전부 못 찾는다.
python3 solve.py
7.9초. 적중이 21/24 로 나오는 건 값이 겹치는 인덱스가 셋 있어서 서로 다른 해시값이 21개뿐이기 때문이다.
FLAG: GoN{wait_i_can_handle_my_fault_plz_don_t_hit_me}"내 fault 는 내가 알아서 처리할 수 있으니 때리지 말아 달라." 문제 페이지의 짤과 이어지는 농담이다.
✅ 검증 — 두 방향으로
먼저 문제 바이너리에 그대로 먹여 본다. 대조군으로 48글자 A 도 같이 돌린다.
#!/usr/bin/env python3
"""찾은 flag 를 문제 바이너리에 그대로 먹여 최종 확인한다."""
import subprocess
flag = "GoN{wait_i_can_handle_my_fault_plz_don_t_hit_me}"
r = subprocess.run(["./extracted/nonsense", flag], capture_output=True, text=True)
print(f"argv[1] : {flag}")
print(f"len : {len(flag)}")
print(f"stdout : {r.stdout.strip()!r}")
print(f"exitcode: {r.returncode}")
bad = "A" * 48
r2 = subprocess.run(["./extracted/nonsense", bad], capture_output=True, text=True)
print(f"\n대조군 'A'*48 -> stdout: {r2.stdout.strip()!r}")python3 verify.py
두 번째는 gdb 로 내부 상태를 직접 본다. SIGSEGV 를 프로그램에 그대로 넘겨(handle SIGSEGV pass)
핸들러가 24번 다 돌게 두고, 세 지점에서 값을 읽는다.
# gdb_counter.gdb — SIGSEGV 를 프로그램에 그대로 넘겨 핸들러가 24번 다 돌게 두고 세 지점을 읽는다.
# ① 0xb82 핸들러가 복귀시킨 지점의 RAX
# ② 0xb90 main 이 누적합을 검사하는 지점의 [rbp-8]
# ③ 0xa7f .fini_array 판정 함수 직전의 .bss 카운터
set pagination off
set confirm off
run
set $base = $rip - 0xb7f
handle SIGSEGV nostop noprint pass
break *($base + 0xb82)
continue
printf "\n[핸들러 복귀] RIP = %p = base+0xb82 (0xb7f + 3, 폴트 명령을 건너뜀)\n", $rip
printf "[핸들러 복귀] RAX = %d <- 0 으로 덮여서 main 의 누적합이 안 늘어난다\n", $rax
delete breakpoints
break *($base + 0xb90)
continue
printf "\n[main 누적합] rbp-8 = %ld <- 0 이라 'wrong...' 을 건너뛴다\n", *(long*)($rbp - 0x8)
delete breakpoints
break *($base + 0xa7f)
continue
printf "\n[.bss 0x206100] counter = %d <- 0x18(24) 이어야 correct!!\n", *(long*)($base + 0x206100)
continue
quitgdb -q -batch -x gdb_counter.gdb --args extracted/nonsense 'GoN{wait_i_can_handle_my_fault_plz_don_t_hit_me}'
세 값이 전부 예상과 같다. 폴트 명령 바로 뒤에서 RAX 는 0, main 의 누적합도 0,
소멸자 직전 .bss 카운터는 24. 그리고 correct!!.
문제 폴더의 reproduce.sh 하나로 이 과정을 통째로 다시 돌릴 수 있게 해 뒀다.
#!/usr/bin/env bash
# Nonsense (DreamHack 455 / Gold 1 reversing) — 한 방 재현.
# solve.py 가 DF_1_PIE 를 끈 사본을 만들고, 문제 바이너리의 해시를 dlopen 으로
# 직접 불러 2바이트 전수조사 → flag 복원 → 문제 바이너리로 correct!! 까지 확인한다.
# 필요한 것: python3, gcc 없음, 인터넷 없음. 로컬 glibc 만 있으면 된다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
EXPECT=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null || echo '')
chmod +x extracted/nonsense
OUT=$(timeout 600 python3 solve.py 2>&1) || true
echo "$OUT" | tail -8
FLAG=$(printf '%s' "$OUT" | grep -aoE 'GoN\{[^}]+\}' | head -1)
if [ -n "$FLAG" ] && { [ -z "$EXPECT" ] || [ "$FLAG" = "$EXPECT" ]; }; then
echo; echo "✅ PASS $FLAG"; exit 0
fi
echo; echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '$EXPECT')"; exit 1로컬 환경
| 항목 | 값 |
|---|---|
| OS | Ubuntu 25.10 (x86-64) |
| glibc | 2.42 |
| gdb | 17.2 |
| binutils | objdump 2.45 |
| python | 3.13 |
| 실행 시간 | solve.py 약 8초 (전수조사 9,025회) |
바이너리 자체는 Ubuntu 18.04 의 GCC 7.5 로 빌드됐지만, libc 에 의존하는 부분이
malloc/memset 정도라 최신 glibc 에서도 그대로 돈다. 패치엘프도 도커도 필요 없었다.
📝 결론
정답을 코드가 아니라 예외로 전달한다.
이 문제의 핵심은 해시가 아니다. 해시는 입력이 2바이트라 애초에 깰 필요도 없었다.
진짜 장치는 "정답 비교"를 main 밖으로 빼낸 것이다. 비교하는 코드는 시그널 핸들러에 있고,
그 핸들러를 호출하는 건 CPU 다. main 을 아무리 정독해도 call 도 jmp 도 안 보인다.
디컴파일러도 마찬가지라, 그래프만 보면 "합이 0이면 통과"라는 잘못된 그림이 나온다.
"안 쓰이는 문자열"이 제일 좋은 단서였다.
막혔을 때 실마리가 된 건 디스어셈블리가 아니라 .rodata 였다. correct!! 가 있는데
그 문자열을 참조하는 코드가 main 에 없다. 성공 문자열이 있으면 성공 경로도 어딘가 있다.
sigaction 임포트가 있는데 부르는 데가 없다는 것도 같은 종류의 신호였다.
"있는데 안 쓰인다"는 대부분 내가 아직 못 찾은 것이다.
재구현하지 않고 원본을 오라클로 쓰는 게 거의 항상 낫다.
544라운드 NLFSR 을 파이썬으로 옮기는 건 반나절짜리 일이고, 탭 하나를 잘못 읽으면
"후보 0개"라는 정보 없는 실패만 돌아온다. DF_1_PIE 비트 하나 끄고 dlopen 하면
그 반나절이 5분이 된다. 재구현은 원본을 못 돌릴 때의 차선책이지 기본값이 아니다.
방어 관점 — 이 패턴은 실제 악성코드에도 있다.
시그널 핸들러로 제어흐름을 숨기는 방식은 리눅스 패커·안티분석 루틴에서 실제로 쓰인다.
정적 분석 도구는 시그널 경로를 따라가지 못하니, 분석 파이프라인이라면
sigaction/signal 호출과 .init_array 등록 함수를 별도로 뽑아 보는 단계가 필요하다.
동적으로는 ptrace 로 시그널을 가로채 si_addr 과 ucontext 변경 여부를 로깅하면
이 문제의 정답표를 그대로 뽑아낼 수 있다. 실제로 위의 gdb 스크립트가 그 축소판이다.
한 가지 덧붙이면, 이 바이너리는 .text 의 68%가 죽은 코드였다.
바이너리 크기나 함수 개수로 난이도를 가늠하면 안 된다는 뜻이기도 하다.
호출 그래프를 먼저 뽑고 도달 불가능한 덩어리를 걷어내면, 20KB 짜리 문제가 6KB 짜리가 된다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…