[🥇 Gold 1] 세그폴트가 곧 정답이다 — DreamHack Nonsense 풀이

2026-08-19·1분 읽기·

[🥇 Gold 1] 세그폴트가 곧 정답이다 — DreamHack Nonsense 풀이

main 은 해시 결과인 32비트 값을 포인터로 역참조해 매번 SIGSEGV 를 낸다. .init_array 가 걸어 둔 핸들러가 폴트 주소를 정답표와 대조하고 레지스터를 고쳐 재개하므로, 판정은 main 이 아니라 폴트 24번의 주소열이 한다. 해시는 입력이 2바이트뿐이라 바이너리 자체를 dlopen 해 오라클로 쓰고 전수조사로 48글자를 복원했다.

문제: DreamHack 워게임 — Nonsense 분류: reversing 난이도: 🥇 Gold 1 FLAG: GoN{wait_i_can_handle_my_fault_plz_don_t_hit_me}

문제 설명이 두 줄이다. "이 바이너리의 루틴은 말이 안 된다", "리버싱하지 말고 그냥 출제자를 두들겨 패자." 그 아래에는 야구방망이를 든 참가자가 출제자를 쫓는 짤이 붙어 있다.

드림핵 워게임 455번 Nonsense 문제 페이지 — 리버싱 장비로 출제자를 쫓는 짤과 함께 문제 설명 두 줄, 그리고 파일 다운로드 버튼이 보인다
드림핵 워게임 455번 Nonsense 문제 페이지 — 리버싱 장비로 출제자를 쫓는 짤과 함께 문제 설명 두 줄, 그리고 파일 다운로드 버튼이 보인다

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

file 과 readelf, nm -D 로 확인한 배포본 정보 — 64비트 PIE 이고 심볼이 스트립됐으며 임포트 목록에 read 나 scanf 계열이 하나도 없다
file 과 readelf, nm -D 로 확인한 배포본 정보 — 64비트 PIE 이고 심볼이 스트립됐으며 임포트 목록에 read 나 scanf 계열이 하나도 없다

스트립된 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=$?"

인자 없이, 4글자로, 48글자로 각각 실행한 결과 — 앞의 둘은 아무 출력 없이 rc가 1이고 48글자일 때만 wrong 이 나온다
인자 없이, 4글자로, 48글자로 각각 실행한 결과 — 앞의 둘은 아무 출력 없이 rc가 1이고 48글자일 때만 wrong 이 나온다

인자가 없거나 짧으면 조용히 _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...' 을 건너뛴다|

objdump 로 뜬 main 함수 디스어셈블리 — argc 검사와 길이 48 검사, 2바이트씩 읽어 해시를 부르고 그 반환값을 다시 역참조하는 구조가 보인다
objdump 로 뜬 main 함수 디스어셈블리 — argc 검사와 길이 48 검사, 2바이트씩 읽어 해시를 부르고 그 반환값을 다시 역참조하는 구조가 보인다

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 등록|

readelf 로 본 INIT_ARRAY 와 FINI_ARRAY 항목 그리고 그 안의 함수 주소들, 이어서 objdump 로 본 생성자가 SIGSEGV 핸들러를 등록하는 코드
readelf 로 본 INIT_ARRAY 와 FINI_ARRAY 항목 그리고 그 안의 함수 주소들, 이어서 objdump 로 본 생성자가 SIGSEGV 핸들러를 등록하는 코드

.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개까지 훑는다|

objdump 로 뜬 SIGSEGV 핸들러 디스어셈블리 — ucontext 에서 RIP 를 꺼내 main 더하기 0x75 인지 확인하고 RAX 를 0 으로 RIP 를 3 만큼 전진시킨다
objdump 로 뜬 SIGSEGV 핸들러 디스어셈블리 — ucontext 에서 RIP 를 꺼내 main 더하기 0x75 인지 확인하고 RAX 를 0 으로 RIP 를 3 만큼 전진시킨다

의사코드로 정리하면 이렇다.

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 가 오른다.

Nonsense 의 제어흐름 도식 — main 의 역참조가 SIGSEGV 를 내고 핸들러가 폴트 주소를 정답표와 대조한 뒤 RAX 와 RIP 를 고쳐 재개시키는 경로
Nonsense 의 제어흐름 도식 — main 의 역참조가 SIGSEGV 를 내고 핸들러가 폴트 주소를 정답표와 대조한 뒤 RAX 와 RIP 를 고쳐 재개시키는 경로

즉 이 문제의 정답은 "폴트 주소 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

dump_targets.py 실행 결과 — .data 에서 뽑은 정답 24개와 값이 겹치는 인덱스 쌍 세 개, 그리고 뒤따르는 17바이트 초기상태
dump_targets.py 실행 결과 — .data 에서 뽑은 정답 24개와 값이 겹치는 인덱스 쌍 세 개, 그리고 뒤따르는 17바이트 초기상태

값이 겹치는 인덱스가 셋 있다 — [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
quit
gdb -q -batch -x gdb_fault.gdb --args extracted/nonsense AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

gdb 배치 실행 결과 — 48글자 A 를 넣었을 때 main 더하기 0x75 에서 SIGSEGV 가 나고 si_addr 이 정답표 0번과 다르다는 것이 보인다
gdb 배치 실행 결과 — 48글자 A 를 넣었을 때 main 더하기 0x75 에서 SIGSEGV 가 나고 si_addr 이 정답표 0번과 다르다는 것이 보인다

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바이트를 짜낸다|

objdump 로 본 해시 진입점 0x57e3 — 스택에 상태를 잡고 초기화 흡수 짜내기 세 함수를 차례로 부르는 스펀지 구조가 드러난다
objdump 로 본 해시 진입점 0x57e3 — 스택에 상태를 잡고 초기화 흡수 짜내기 세 함수를 차례로 부르는 스펀지 구조가 드러난다

세 함수가 순서대로 불린다. 각각을 읽어 보면 전형적인 스펀지 구조다.

함수하는 일
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 라운드|

objdump 로 본 permutation 진입부와 루프 조건 — malloc 으로 버퍼 세 개를 잡고 라운드 카운터가 0x21f 까지 도는 것이 보인다
objdump 로 본 permutation 진입부와 루프 조건 — malloc 으로 버퍼 세 개를 잡고 라운드 카운터가 0x21f 까지 도는 것이 보인다

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

find_dead.sh 실행 결과 — 대형 함수 네 개 중 0xc51 만 참조가 하나 있고 나머지 셋은 참조 수가 0 이라 죽은 코드로 판정된다
find_dead.sh 실행 결과 — 대형 함수 네 개 중 0xc51 만 참조가 하나 있고 나머지 셋은 참조 수가 0 이라 죽은 코드로 판정된다

셋 다 참조가 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 executable

glibc 가 .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

probe.py 실행 결과 — 배포본 dlopen 은 거부되고 비트를 끈 사본은 열리며 두 글자 해시 값이 정답표 앞쪽 두 항목과 정확히 일치한다
probe.py 실행 결과 — 배포본 dlopen 은 거부되고 비트를 끈 사본은 열리며 두 글자 해시 값이 정답표 앞쪽 두 항목과 정확히 일치한다

한 번 호출에 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

solve.py 실행 결과 — 정답표 24개를 출력한 뒤 9025 조합을 7.9초 만에 훑어 48글자 플래그를 복원하고 문제 바이너리로 correct 를 받는다
solve.py 실행 결과 — 정답표 24개를 출력한 뒤 9025 조합을 7.9초 만에 훑어 48글자 플래그를 복원하고 문제 바이너리로 correct 를 받는다

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

verify.py 실행 결과 — 복원한 48글자를 넣으면 correct 가 나오고 종료코드가 0 이며 대조군 48글자 A 는 wrong 으로 갈린다
verify.py 실행 결과 — 복원한 48글자를 넣으면 correct 가 나오고 종료코드가 0 이며 대조군 48글자 A 는 wrong 으로 갈린다

두 번째는 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
quit
gdb -q -batch -x gdb_counter.gdb --args extracted/nonsense 'GoN{wait_i_can_handle_my_fault_plz_don_t_hit_me}'

gdb 배치 실행 결과 — 핸들러 복귀 지점에서 RAX 가 0 이고 main 의 누적합도 0 이며 소멸자 직전 카운터가 24 에 도달해 correct 가 출력된다
gdb 배치 실행 결과 — 핸들러 복귀 지점에서 RAX 가 0 이고 main 의 누적합도 0 이며 소멸자 직전 카운터가 24 에 도달해 correct 가 출력된다

세 값이 전부 예상과 같다. 폴트 명령 바로 뒤에서 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

로컬 환경

항목값
OSUbuntu 25.10 (x86-64)
glibc2.42
gdb17.2
binutilsobjdump 2.45
python3.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

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥇 Gold 3] 공유 라이브러리 1,257개로 만든 오토마타 — DreamHack .so what? 풀이
blog

[🥇 Gold 3] 공유 라이브러리 1,257개로 만든 오토마타 — DreamHack .so what? 풀이

main 은 64자 hex 를 받아 dlopen 과 dlsym 만으로 검사한다. .so 하나가 상태이고 f_d 가 라벨 d 인 전이라, 파일 1,257개가 통째로 결정적 오토마타다. 전이표를 objdump 로 뽑아 길이 63 경로를 찾으면 간선 19,792개 중 성공 경로가 정확히 하나 나온다.
#dreamhack#ctf#reversing+6
2026-08-07#dreamhack +4
[🥈 Silver 3] strncmp 는 8바이트를 다 보지 않는다 — DreamHack Broken Password 풀이
blog

[🥈 Silver 3] strncmp 는 8바이트를 다 보지 않는다 — DreamHack Broken Password 풀이

/dev/urandom 으로 만든 8바이트 비밀번호를 strncmp 로 검사한다. 그런데 strncmp 는 NUL 을 만나면 거기서 멈추고 같다고 답한다. 첫 바이트가 0x00 으로 나오는 1/256 만 노리면 개행 한 글자로 통과한다. 더 그럴싸해 보이는 임의 주소 쓰기 경로는 주소 유출이 없어 쓸 수 없는 미끼였다.
#dreamhack#ctf#misc+6
2026-08-07#dreamhack +4
[🥈 Silver 2] MD5를 잘게 썰면 "충돌 저항성"이라는 말이 무색해진다 — DreamHack hash-browns 풀이
blog

[🥈 Silver 2] MD5를 잘게 썰면 "충돌 저항성"이라는 말이 무색해진다 — DreamHack hash-browns 풀이

스트립된 리버싱 바이너리 하나가 전부다. 입력을 받아 27바이트로 자르고, 그걸 다시 3바이트씩 9조각으로 쪼갠 뒤 각 조각의 MD5를 계산해 바이너리 안에 박혀 있는 16바이트 값과 비교한다. 27바이트 전체를 한 번에 맞히려 들면 무차별대입은 우주 나이보다 오래 걸리지만, 3바이트짜리 조각 하나의 MD5 역상은 파이썬으로 0.2초면 찾는다. 해시 함수 자체는 멀쩡한데, "작은 조각으로 쪼개 각각 검증한다"는 설계 하나가 전체 키공간을 9개의 훨씬 작은 문제로 무너뜨린다.
#dreamhack#ctf#reversing+5
2026-07-21#dreamhack +4