[rev] DiceCTF 2026 - another-onion 문제 풀이
이번 글에서는 DiceCTF 2026의 another-onion 문제를 풀어보겠습니다.
이 문제는 처음 보면 꽤 겁을 줍니다 ㅋㅋ
무려 512바이트짜리 키가 등장하고 바이너리도 평범하게 생기지 않았거든요.
그래서 처음에는 "이 긴 키를 전부 복구해야 하나?"라는 생각이 들기 쉽습니다.
그런데 막상 분석을 시작해 보면 이 문제의 핵심은 살짝 다른 곳에 있는데요,
오히려 다른 부분에서 중요한 단서가 새고 있고 그걸 잘 잡으면 훨씬 수월하게 풀 수 있습니다.
이번 글에서는 초보자분들도 흐름을 따라오실 수 있도록 처음 나오는 개념은 짧게라도 설명하면서 진행해 보겠습니다.
## 1. 문제를 처음 봤을 때
문제 바이너리 이름은 an_onion입니다.
이 파일은 x86-64 ELF 형식인데요, ELF는 리눅스에서 사용하는 실행 파일 형식이라고 생각하시면 됩니다.
윈도우즈에서 exe 파일 처럼요.
또 이 바이너리는 stripped 상태였습니다.
이 말은 함수 이름이나 디버깅에 도움이 되는 정보가 지워져 있다는 뜻입니다.
그래서 분석할 때 "여기가 무슨 함수지?"를 바로 알기 어렵습니다.
그리고 non-PIE 바이너리이기도 했습니다.
이건 실행할 때 코드 주소가 크게 흔들리지 않는 형태라고 보시면 됩니다.
리버싱할 때는 이런 특성이 있으면 주소를 추적하기가 조금 더 편한(?) 편입니다.
임포트된 함수도 아주 적었습니다.
- mmap
- read
- putc
- mprotect
이 정도만 봐도 조금 수상합니다.
평범한 입력 검증 프로그램이라면 조금 더 일반적인 함수 구성이 나오는 경우가 많은데 여기서는 코드 메모리와 관련 있는 함수들이 보여서 "실행 중에 뭔가 바꾸는 건 아닐까?"라는 생각을 하게 됩니다.
특히 mprotect는 메모리 권한을 바꾸는 함수라서 self-modifying code를 의심하게 만드는 대표적인 단서 중 하나입니다.
self-modifying code는 말 그대로 프로그램이 실행 도중 자기 자신의 코드를 바꿔 가며 동작하는 방식입니다.
## 2. 직접 실행해 보면 보이는 점
샘플 바이너리를 실행해 보면 문제의 성격이 더 분명해집니다.
sh
./an_onion < a_key
입력이 맞으면 다음과 같은 문구가 출력됩니다.
Congratulations! Please submit the following token to get the flag:
0123456789abcdef0123456789abcdef
여기서 중요한 건 입력이 틀렸을 때입니다.
보통은 "wrong" 같은 메시지를 띄우고 끝날 것 같지만 이 문제는 그냥 프로그램이 죽어 버립니다.
이게 왜 중요할까요?
보통 단순 비교 문제라면 실패할 때도 비교적 얌전하게 끝나는 경우가 많습니다.
그런데 여기서는 아예 크래시가 난다는 건, 내부에서 고정된 비교 루틴을 타는 게 아니라 어떤 코드가 실행 중에 풀리거나 바뀌고 있을 가능성이 높다는 뜻입니다.
즉 이 문제는 "입력이 맞냐 틀리냐"만 보는 간단한 프로그램이 아니라 숨겨진 코드를 실행 중에 드러내는 구조일 수 있다는 이야기입니다.
## 3. 정적 분석만으로는 잘 안 보였던 이유
이 바이너리를 정적으로 디스어셈블해 보면 코드가 꽤 엉망으로 보입니다.
처음 보면 "디스어셈블이 잘못된 건가?" 싶을 정도로 흐름이 자연스럽지 않습니다.
그 이유는 생각보다 단순했습니다.
이 바이너리가 실행 도중 .text 섹션, 즉 실제 기계어 코드가 들어 있는 영역을 직접 수정하고 있었기 때문입니다.
쉽게 말해, 파일 안에 들어 있는 원본 코드는 완성본이 아니고 실행하면서 나중에 진짜 코드로 바뀌는 구조였던 겁니다.
샘플에서는 main의 끝부분인 0x407a57 근처에서 브레이크를 건 뒤 메모리를 덤프해 보면 복호화된 실제 코드를 확인할 수 있습니다.
여기서 "메모리를 덤프한다"는 건 프로그램이 실행 중인 상태에서 메모리에 올라간 내용을 그대로 꺼내 보는 것이라고 생각하시면 됩니다.
파일에 들어 있던 원본이 아니라 실행 후 실제로 변형된 결과를 보는 방법입니다.
## 4. 실제로 복호화되는 코드는 무엇이었을까
메모리를 덤프해서 확인해 보면 성공 메시지를 출력하기 직전의 루틴이 아래처럼 보입니다.
asm
4071a0: mov eax, ecx
4071a2: and eax, 0x7f
4071a5: movzx eax, byte ptr [rdx+rax]
4071a9: xor byte ptr [rcx+0x4072b0], al
...
4071bc: jmp 0x4072b0
처음 보면 어지럽지만 핵심만 보면 그렇게 복잡하지 않습니다.
이 코드는 대략 이런 일을 합니다.
1. 128바이트 길이의 반복 키를 가져옴
2. 그 키로 특정 코드 구간을 XOR 방식으로 복호화
3. 복호화가 끝난 뒤 그 위치로 점프해서 실행
여기서 XOR은 리버싱 문제에서 아주 자주 등장하는 연산입니다.
같은 값으로 한 번 XOR하면 암호화, 다시 한 번 XOR하면 복호화가 되는 특징이 있어서 간단한 난독화에 자주 쓰입니다.
문제에서 실제로 복호화되는 구간은 0x4072b0..0x407955입니다.
그렇다면 이제 자연스럽게 이런 질문이 생깁니다.
"복호화된 뒤에 저 구간은 무슨 일을 할까?"
## 5. 복호화된 코드는 생각보다 단순했다
복호화가 끝난 뒤 코드를 보면 복잡한 검증 루틴이 나오는 게 아닙니다.
오히려 대부분이 문자 출력 코드의 반복입니다.
대표적인 형태는 이렇습니다.
asm
mov edi, imm32
call putc
putc는 문자 하나를 출력하는 함수입니다.
그리고 imm32는 명령어 안에 바로 박혀 있는 숫자값이라고 생각하시면 됩니다.
즉 "이 문자를 출력해라"라는 명령이 길게 반복되고 있는 셈입니다.
정리하면 이 루틴은 문자 하나씩 계속 찍어 가며 문자열을 출력하는 코드였습니다.
출력되는 내용은 두 부분으로 나뉩니다.
- 앞부분: 고정된 성공 메시지
- 뒷부분: 32자리 16진수 토큰
여기서 아주 중요한 포인트가 나옵니다.
고정된 성공 메시지는 이미 우리가 알고 있습니다.
즉, 복호화된 결과의 상당 부분을 미리 알고 있다는 뜻입니다.
## 6. 문제의 핵심
처음에는 512바이트 키를 복구해야 할 것처럼 보였습니다.
다만 우리가 이미 알고 있는 문장이 복호화된 코드 안에 들어 있다면 굳이 전체 검증 로직을 다 뜯어보지 않아도 됩니다.
오히려 그 알려진 내용을 이용해서 복호화에 쓰인 반복 키를 되찾는 쪽이 훨씬 쉽습니다.
이런 공격 방식을 known-plaintext attack이라고 부릅니다.
용어를 짧게 정리해 보겠습니다.
- 평문: 암호화되기 전 원래 데이터
- 암호문: 암호화된 뒤의 데이터
- 알려진 평문 공격: 원래 내용 일부를 이미 알고 있을 때 그 정보를 이용해 암호 방식을 거꾸로 추적하는 방법
이 문제는 딱 이 구조로 풀립니다.
즉, 512바이트 키 전체를 복구하는 문제처럼 보이지만 실제로는 성공 출력 루틴에 쓰인 128바이트 XOR 패드만 알아내면 됩니다.
## 7. 라이브 바이너리에서는 어디를 보면 될까
이제 실제 서버에서 받은 바이너리에서도 같은 구조를 찾으면 됩니다.
이번에는 굳이 실행하지 않고도 출력 루틴 위치를 찾을 수 있었습니다.
방법은 다음과 같습니다.
1. 먼저 고유한 꼬리 바이트 시퀀스 48 83 c4 08 c3 b8 10 04 40 00 c3를 찾습니다.
2. 그 바로 앞 4바이트를 주소값으로 해석합니다.
여기서 4바이트 단위 값을 dword라고 부르는데 초보자 입장에서는 그냥 "32비트 정수" 정도로 이해하셔도 충분합니다.
이번 인스턴스에서 얻은 주소는 다음과 같았습니다.
0x4d7720
샘플 바이너리와 레이아웃이 거의 같았기 때문에 출력 루틴 구간도 아래처럼 추정할 수 있었습니다.
encrypted printer start = retaddr - 0x6b0 = 0x4d7070
encrypted printer end = retaddr - 0xb = 0x4d7715
즉 0x4d7070부터 0x4d7715까지를 암호문 구간으로 보고 살펴보면 됩니다.
## 8. 복호화 뒤 나와야 할 내용을 먼저 만들어 보기
다음 단계는 "이 코드가 풀리면 원래 어떤 모습이어야 할까?"를 재구성하는 것입니다.
구조는 대략 이렇게 생겼습니다.
- push rax
- mov rsi, [stdout]
- mov edi, imm32 ; call putc가 100번 반복
그리고 이 100개의 출력 중에서
- 앞의 68개는 고정된 성공 메시지
- 뒤의 32개는 우리가 아직 모르는 토큰
에 해당합니다.
이 말은 곧 전체 100개 중 68개는 이미 아는 정보라는 뜻이고
문제에서 진짜로 숨겨진 부분은 마지막 32글자뿐입니다.
이런 상황에서는 굳이 모든 걸 다 역산하려고 하기보다 이미 아는 68개를 적극적으로 활용하는 편이 훨씬 유리합니다.
## 9. XOR 패드 복원
지금까지 모은 정보를 다시 정리해 보겠습니다.
- 암호화된 출력 루틴 바이트를 가지고 있습니다.
- 복호화된 결과의 대부분을 이미 알고 있습니다.
- 암호화 방식은 128바이트 주기의 반복 XOR입니다.
1. 암호문 구간에서 바이트를 가져옵니다.
2. 알고 있는 평문과 XOR해서 패드 값을 구합니다.
3. 128바이트 주기로 반복되는 위치끼리 값이 충돌하지 않는지 확인합니다.
이번 인스턴스에서는 알려진 평문만으로 128바이트 패드를 전부 복원할 수 있었습니다.
즉 중간에 모순이 생기지 않았고 키가 깔끔하게 맞아떨어졌다는 뜻입니다.
그리고 이 패드를 다 구한 순간 마지막 32글자도 바로 풀립니다.
그렇게 얻은 라이브 토큰은 아래와 같았습니다.
2e735210bee830357f49b1cad5d11cbe
## 10. 토큰
복구한 토큰은 다음과 같이 제출했습니다.
sh
printf '2e735210bee830357f49b1cad5d11cbe\n' | \
openssl s_client -quiet -connect another-onion-submission-6f6c8e7b5529.ctfi.ng:1337
최종 플래그
dice{5p33d_1s_a1s0_imp0rtant_1n_r3ver5e_3ngineer1ng}
## 11. 마무리
이 문제는 일부러 512바이트 키를 전면에 내세워서 풀이 방향을 헷갈리게 만듭니다. 어찌보면 anti llm적인 요소라고 볼 수도 있겠네요.
하지만 실제로는 그 긴 키보다 성공 메시지를 출력하는 루틴 쪽이 더 큰 취약점이었습니다.
풀이 흐름을 다시 짧게 정리하면 이렇습니다.
1. 성공 출력 루틴이 암호화된 위치를 찾는다
2. 복호화되었을 때 나와야 할 내용을 최대한 재구성
3. 그 정보를 이용해 반복 XOR 패드를 복원
4. 남은 32글자 토큰을 복호화
5. 토큰을 제출
결과적으로 가장 어려워 보이던 512바이트 키 복구는 아예 하지 않아도 됐습니다.
대회를 뛰다보면 문제를 정면으로 전부 이해하려고 하기보다 어디에서 구조적인 정보가 새고 있는지 먼저 보는 편이 더 빠를 때가 많습니다.
이 문제도 그런 케이스였는데요,
겉으로는 복잡해 보여도 출력 루틴 안에 우리가 이미 아는 문장이 많이 들어 있다는 점만 눈치채면 훨씬 쉽게 풀 수 있었습니다.
초보자 입장에서는 이런 문제를 풀 때 "모든 코드를 완벽히 이해해야 한다"는 부담을 먼저 내려놓는 게 중요합니다.
실제로는 전체를 다 아는 것보다 공격 가능한 약한 지점을 먼저 찾는 쪽이 훨씬 실전적일 때가 많습니다.