문제: DreamHack — photographer 분류: reversing 난이도: 🥈 Silver 3 FLAG:
DH{d85a130283f58035eb327b56e37d2dd087156ba629687a5d52bd4af3c5bfa4f4}
문제 설명은 한 줄이다. "I lost my big-secret photo..."
받은 건 잃어버렸다는 사진의 잔해 하나와, 그 사진을 그 꼴로 만든 프로그램 하나다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | photographer |
| 난이도 | 🥈 Silver 3 |
| 분류 | reversing |
| 제공 파일 | prob (26,848B, stripped PIE C++), flag.bmp.enc (6,220,854B) |
| 서버 | 없음 — 파일만 준다 |
| 핵심 기법 | 고정 시드 srand(0xBEEF) + 바이트별 가역 변환의 역산 |
prob은 flag.bmp를 읽어 flag.bmp.enc를 뱉는 한 방향 변환기다. 우리가 가진 건 결과물뿐이니, 그 변환을 읽어서 거꾸로 돌리면 된다.
되돌린 BMP를 열면 플래그가 그림으로 그려져 있다.
🔬 정찰 — 받은 것 두 개
먼저 뭘 받았는지부터 본다.
ls -l extracted && file extracted/* && xxd -l 64 extracted/flag.bmp.enc
flag.bmp.enc는 file이 data라고만 한다. BMP라면 BM으로 시작해야 하는데 첫 두 바이트가 72 35다. 헤더까지 건드렸다는 뜻이다.
그런데 헥스를 보면 규칙이 하나 눈에 띈다. 00과 ff가 세 바이트 주기로 계속 나온다. 첫 줄 7235 ed32 e597 b400 9fea d841 9900 dc4b에서 00은 오프셋 7과 13에 있다. 둘 다 3으로 나눈 나머지가 1이다. 그 아래 줄들의 ff도 마찬가지다.
24비트 BMP는 픽셀 하나가 B, G, R 세 바이트다. 그러니 "세 바이트 주기"를 보면 픽셀 구조가 살아 있다고 — 값은 그대로고 자리만 섞였다고 — 생각하기 쉽다. 여기서 한 번 헛짚었다.
다음은 바이너리가 뭘 링크했는지다. stripped라도 PLT는 남는다.
objdump -d -M intel extracted/prob | grep -E "@plt>:" | grep -iE "rand|time|memmove|alarm|clock"
rand@plt와 srand@plt가 있다. 난수를 쓴다.
그런데 time@plt도 clock@plt도 없다. 시드를 실행 시각에서 뽑지 않는다는 뜻이다. 시드가 상수면 난수열이 매 실행 똑같고, 그러면 되돌릴 수 있다. 이 한 줄이 문제를 풀 수 있게 만든다.
🐛 삽질 — 자리를 섞었다고 믿었다
▶🐛 삽질 — '픽셀 셔플'이라는 잘못된 첫 가설
헥스의 세 바이트 주기 + rand() 사용. 이 둘을 붙이니 자연스럽게 하나의 그림이 떠올랐다.
픽셀(또는 바이트)의 자리를 난수로 섞은 것이다. 값은 그대로니까
00·ff같은 채널값이 살아 있고,std::shuffle이나 Fisher–Yates를 돌렸을 것이다.
그래서 처음엔 memmove@plt가 있는 것도 "블록을 옮긴다"는 증거로 읽었다. 셔플이면 역셔플만 만들면 되니 main을 대충 훑고 rand() % n 패턴만 찾으려 했다.
틀렸다. main에는 두 원소를 맞바꾸는 코드가 없다. mov BYTE PTR [rax], bl — 읽은 자리에 그대로 되쓴다. 자리는 한 번도 안 바뀐다.
세 바이트 주기의 정체도 셔플이 아니었다. 아래에서 보겠지만 변환은 i % 3으로 세 갈래로 갈리고, 그중 i%3==1 갈래가 회전만 한다. 그런데 00000000과 11111111은 몇 칸을 돌려도 자기 자신이다. 원본 BMP는 흰 배경(ff ff ff)과 검은 글자(00 00 00)가 대부분이니, 그 자리의 00·ff는 변환을 통과해도 그대로 남는다.
실제로 앞 4KB에서 ff 1353개 중 1347개가 i%3==1 자리에 있다. 픽셀 구조가 살아남은 게 아니라, 세 갈래 중 하나가 극단값을 보존한 것뿐이다.
가설이 관측을 설명하는 것처럼 보인다고 맞는 건 아니다. memmove@plt도 셔플의 증거가 아니라 그냥 std::vector가 커질 때 쓰는 것이었다.
🔍 main 을 읽는다
main은 심볼이 없다. 엔트리(_start)가 __libc_start_main에 넘기는 첫 인자를 보면 0x24fb다.
objdump -d -M intel --start-address=0x24fb --stop-address=0x2540 extracted/prob | sed -f annot_seed.sed
첫 줄부터 답이 나온다.
251a: bf ef be 00 00 mov edi,0xbeef ; 시드
251f: e8 6c fd ff ff call 2290 <srand@plt> ; srand(0xbeef)srand(0xBEEF). 프로그램이 뭘 하든 난수열은 항상 같다.
이어서 lea로 집는 두 주소 0x4004, 0x400d가 각각 ifstream과 ofstream 생성자로 들어간다. 그 자리에 뭐가 있는지는 .rodata를 그대로 덤프하면 보인다.
objdump -s -j .rodata extracted/prob
flag.bmp를 읽어 flag.bmp.enc로 쓴다. 파일 전체를 std::vector<char>에 담고, 인덱스를 0부터 끝까지 훑는 루프가 이어진다.
루프 본체 첫머리는 이렇게 생겼다.
objdump -d -M intel --start-address=0x2649 --stop-address=0x2699 extracted/prob | sed -f annot_dispatch.sed
0xaaaaaaaaaaaaaaab는 컴파일러가 3으로 나누는 나눗셈을 곱셈으로 바꿀 때 쓰는 상수다. 그걸로 i / 3을 구하고, i - 3*(i/3)으로 i % 3을 만든 다음 0·1·2 세 갈래로 뛴다.
즉 바이트마다 인덱스에 따라 다른 변환이 걸린다. 아까 헥스에서 본 세 바이트 주기의 정체가 이거다.
💣 세 갈래 변환
세 분기를 차례로 읽는다.
i % 3 == 0
objdump -d -M intel --start-address=0x2699 --stop-address=0x26fe extracted/prob | sed -f annot_cases.sed![클릭하여 확대 objdump 로 본 i%3==0 분기 — ror8(b,7) 을 부른 뒤 rand() 를 더하고 movzx 로 하위 1바이트만 남긴 다음 rol8(x,4) 를 걸어 v[i] 에 되쓴다](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-photographer-writeup%2F05_case0.png&w=3840&q=75)
ror8(v[i], 7) → + rand() → 하위 바이트만 남김 → rol8(..., 4). 회전 두 번에 덧셈 한 번이다.
i % 3 == 1
objdump -d -M intel --start-address=0x26fe --stop-address=0x2757 extracted/prob | sed -f annot_cases.sed
여기선 난수가 값이 아니라 회전량으로 들어간다. cdq; shr edx,0x1d; add; and 0x7; sub는 부호 있는 % 8을 인라인한 것인데, rand()는 항상 0 이상이라 실질적으로 rand() & 7이다.
i % 3 == 2
objdump -d -M intel --start-address=0x2757 --stop-address=0x279b extracted/prob | sed -f annot_cases.sed![클릭하여 확대 objdump 로 본 i%3==2 분기 — xor eax,ebx 로 바이트와 rand() 를 XOR 한 뒤 lea ebx,[rax-0x18] 로 0x18 을 빼고 그 하위 바이트를 v[i] 에 되쓴다](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-photographer-writeup%2F07_case2.png&w=3840&q=75)
XOR 후 0x18을 뺀다. 32비트로 계산하지만 저장은 mov BYTE PTR [rax], bl — 하위 1바이트만 남는다. 뺄셈은 mod 256에서 하위 바이트만 보면 되니 상위 비트는 신경 쓸 필요가 없다.
세 갈래를 한 장으로 정리하면 이렇다.
![클릭하여 확대 세 갈래 변환 파이프라인 도식 — srand(0xbeef) 이후 flag.bmp 의 바이트 v[0], v[1], v[2] 와 난수 r0, r1, r2 가 인덱스 순서로 짝지어지고 i%3 값에 따라 rol8/ror8/XOR 세 식 중 하나가 적용된다](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-photographer-writeup%2F00_pipeline.png&w=3840&q=75)
여기서 중요한 게 두 가지다.
첫째, 세 갈래 모두 rand()를 정확히 한 번씩 부른다. 분기마다 개수가 달랐다면 난수 소비 위치를 따로 추적해야 했을 텐데, 그럴 필요가 없다. i번째 바이트는 i번째 난수를 쓴다.
둘째, 자리를 바꾸는 코드가 없다. 회전·덧셈·XOR·뺄셈뿐이고 전부 8비트 안에서 가역이다. 역함수를 같은 순서로 걸면 그대로 돌아온다.
🔁 회전 헬퍼 두 개
0x2489와 0x24c2가 뭔지 확인한다. 둘 다 인자를 두 개 받는 짧은 함수다.
objdump -d -M intel --start-address=0x2489 --stop-address=0x24c2 extracted/prob | sed -f annot_rot.sed
(b >> n) | (b << (8 - n)) — 오른쪽 회전이다.
objdump -d -M intel --start-address=0x24c2 --stop-address=0x24fb extracted/prob | sed -f annot_rot.sed
이쪽은 시프트 순서가 반대라 왼쪽 회전이다.
두 함수 모두 and DWORD PTR [rbp-0x8], 0x7로 회전량을 0~7로 자른다. n이 0이면 8 - n = 8이 되어 시프트가 바이트 밖으로 밀려나는데, 저장할 때 하위 1바이트만 쓰므로 결과는 원래 값 그대로다. 항등이 되니 따로 예외 처리할 필요는 없다.
sar이 쓰인 게 눈에 걸릴 수 있는데, 바로 앞에 movzx edx, BYTE PTR [rbp-0x4]로 영 확장을 해 둔다. 값이 항상 0~255라 산술 시프트든 논리 시프트든 결과가 같다. 이걸 놓치고 부호 있는 타입으로 옮기면 조용히 어긋난다 — 아래 삽질 2에서 실제로 돌려봤다.
🧪 런타임 확인 — 시드와 난수열
여기까지는 전부 정적 분석이다. 실제로 그렇게 도는지 붙어서 본다.
set pagination off
set confirm off
# 1) srand() 에 실제로 어떤 시드가 들어가는지 — 정적으로 읽은 0xbeef 를 런타임으로 확인
break srand
run
printf "\n[gdb] srand(0x%x) <- main+0x1f 의 mov edi,0xbeef 가 그대로 들어왔다\n\n", $edi
# 2) 그 시드에서 나오는 rand() 앞 세 개 — solve.py 의 ctypes 호출과 같아야 한다
delete 1
break rand
continue
finish
continue
finish
continue
finish
quitgdb -q -batch -x ../verify.gdb ./prob 2>&1 | grep -vE "Missing separate|^Downloading|^Reading|Using host libthread"
세 가지가 한 화면에서 확인된다.
__srandom (x=48879) — 48879는 0xBEEF다. 정적으로 읽은 값이 런타임에도 그대로 들어간다.
rand() 반환값은 순서대로 479531427, 447733150, 1307573299.
그리고 복귀 주소가 ...66cb, ...6703, ...677a다. PIE 베이스를 빼면 0x26cb, 0x2703, 0x277a — 각각 i%3==0, 1, 2 분기 안의 call rand@plt 바로 다음이다. 세 분기가 인덱스 순서대로 난수를 하나씩 가져간다는 게 실행으로 확인된다.
PIE 베이스를 관례로 가정하지 않고 화면에 찍힌 복귀 주소에서 역산했다. 베이스는 환경에 따라 달라진다.
🚀 solve.py
역함수는 그대로 뒤집으면 된다.
i % 3 | 암호화 | 복호화 |
|---|---|---|
| 0 | rol8((ror8(b,7) + r) & 0xff, 4) | rol8((ror8(c,4) - r) & 0xff, 7) |
| 1 | ror8(b, r & 7) | rol8(c, r & 7) |
| 2 | (b ^ r) - 0x18 | ((c + 0x18) & 0xff) ^ (r & 0xff) |
rand()는 다시 구현하지 않았다. glibc의 rand()는 TYPE_3 가산 되먹임 생성기라 손으로 옮기면 초기화 34단계와 워밍업까지 정확히 맞춰야 한다. 한 군데만 틀려도 6백만 바이트가 전부 어긋나고, 그걸 디버깅할 이유가 없다. ctypes로 시스템 libc의 것을 그대로 부른다.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DreamHack photographer (#1998, Silver 3, reversing) — flag.bmp.enc 복호
prob 는 flag.bmp 를 통째로 읽어 한 바이트씩 변형한 뒤 flag.bmp.enc 로 쓴다.
main(0x24fb) 을 그대로 옮기면 이렇다.
srand(0xbeef);
for (i = 0; i < v.size(); i++)
switch (i % 3) {
case 0: v[i] = rol8((ror8(v[i], 7) + rand()) & 0xff, 4); break;
case 1: v[i] = ror8(v[i], rand() & 7); break;
case 2: v[i] = (v[i] ^ rand()) - 0x18; break;
}
ror8/rol8 은 각각 0x2489 / 0x24c2 에 있는 인라인 아닌 헬퍼다.
0x2489: sar edx, cl(n&7) ; shl edx, 8-(n&7) ; or → ROR8
0x24c2: shl edx, cl(n&7) ; sar edx, 8-(n&7) ; or → ROL8
세 분기 모두 바이트당 rand() 를 정확히 한 번 부르므로 난수 소비 순서가 인덱스 순서와 같다.
시드가 0xbeef 상수라 srand/rand 수열은 완전히 결정적이다 — time(NULL) 이 없다는 게
`objdump -d` 의 PLT 목록에서 이미 보인다.
역변환:
i%3==0: b = rol8((ror8(c, 4) - rand()) & 0xff, 7)
i%3==1: b = rol8(c, rand() & 7)
i%3==2: b = ((c + 0x18) & 0xff) ^ (rand() & 0xff)
rand() 는 재구현하지 않고 ctypes 로 **libc 의 것을 그대로** 부른다. glibc 의 TYPE_3
가산 되먹임 생성기를 손으로 옮기다 틀리면 전수가 어긋나는데, 그걸 디버깅할 이유가 없다.
실행:
python3 solve.py # extracted/flag.bmp.enc → flag.bmp
"""
import ctypes
import ctypes.util
from pathlib import Path
HERE = Path(__file__).resolve().parent
ENC = HERE / "extracted" / "flag.bmp.enc"
OUT = HERE / "flag.bmp"
SEED = 0xBEEF
def ror8(b: int, n: int) -> int:
n &= 7
return ((b >> n) | (b << (8 - n))) & 0xFF
def rol8(b: int, n: int) -> int:
n &= 7
return ((b << n) | (b >> (8 - n))) & 0xFF
def main():
libc = ctypes.CDLL(ctypes.util.find_library("c"))
libc.srand(ctypes.c_uint(SEED))
enc = bytearray(ENC.read_bytes())
out = bytearray(len(enc))
for i, c in enumerate(enc):
r = libc.rand()
m = i % 3
if m == 0:
out[i] = rol8((ror8(c, 4) - r) & 0xFF, 7)
elif m == 1:
out[i] = rol8(c, r & 7)
else:
out[i] = ((c + 0x18) & 0xFF) ^ (r & 0xFF)
OUT.write_bytes(bytes(out))
print(f"[*] {ENC.name} {len(enc)} bytes -> {OUT.name}")
print(f"[*] magic : {bytes(out[:2])!r}")
print(f"[*] BITMAPINFO: size={int.from_bytes(out[2:6],'little')} "
f"offset={int.from_bytes(out[10:14],'little')} "
f"w={int.from_bytes(out[18:22],'little')} "
f"h={int.from_bytes(out[22:26],'little')} "
f"bpp={int.from_bytes(out[28:30],'little')}")
if __name__ == "__main__":
main()python3 solve.py && file flag.bmp
BM이 돌아왔다. 헤더가 스스로 1920×1080 24bpp라고 말하고, 선언한 크기 6,220,854가 실제 파일 크기와 정확히 맞는다. 헤더 하나로 이미 복원이 맞았다는 걸 알 수 있다.
🖼️ 되찾은 사진
python3 render.py
플래그는 파일 어디에도 문자열로 들어 있지 않다. 사진에 그려져 있어서 strings로는 절대 안 나온다. 그림을 복원하는 것 자체가 문제였다.
DH{d85a130283f58035eb327b56e37d2dd087156ba629687a5d52bd4af3c5bfa4f4}✅ 검증 — 문제 바이너리로 되돌려 본다
그림이 멀쩡해 보인다고 복호가 정확한 건 아니다. 1비트만 어긋나도 눈으로는 구분이 안 된다.
확실한 검증은 문제가 준 prob을 오라클로 쓰는 것이다. 복원한 flag.bmp를 원래 자리에 놓고 prob을 돌려서, 나온 flag.bmp.enc가 배포본과 바이트 단위로 같은지 본다. 같다면 우리 역함수가 정확히 역함수라는 뜻이다.
cp ../flag.bmp . && ./prob && sha256sum flag.bmp.enc ../extracted/flag.bmp.enc && cmp flag.bmp.enc ../extracted/flag.bmp.enc && echo ROUNDTRIP_OK
해시가 같다. 6,220,854바이트 중 한 바이트도 다르지 않다.
🐛 삽질 2 — sar 을 부호 있는 시프트로 옮기면
▶🐛 삽질 2 — 회전 헬퍼를 int8_t 로 재구현했을 때
0x2489/0x24c2는 회전을 sar(산술 우시프트)로 한다. 어셈블리만 훑고 "산술이니까 부호 확장이겠구나" 하고 int8_t로 옮기면 0x80 이상 바이트에서 결과가 어긋난다.
실제로는 바로 앞에 movzx edx, BYTE PTR [rbp-0x4]가 있어 영 확장을 해 둔다. 그래서 sar이든 shr이든 같다.
틀린 가정만 넣은 버전을 만들어 돌려 봤다.
def ror8_signed(b: int, n: int) -> int:
n &= 7
s = b - 256 if b & 0x80 else b # ← 여기가 틀린 가정: 부호 있는 char 로 본다
return ((s >> n) | (s << (8 - n))) & 0xFF
def rol8_signed(b: int, n: int) -> int:
n &= 7
s = b - 256 if b & 0x80 else b
return ((s << n) | (s >> (8 - n))) & 0xFFpython3 toy_signed.py && file flag_signed.bmp
헤더가 \xc2M이다. file은 여전히 data라고 한다.
그런데 픽셀 영역만 강제로 렌더해 보면 결과가 더 고약하다.

그림은 거의 멀쩡해 보인다. 색이 좀 물들고 잡음이 낀 정도라, 플래그는 그대로 읽힌다. 이 문제에서는 어차피 답을 얻는다.
문제는 "눈으로 봤을 때 그럴듯하면 맞다"고 넘어가는 습관이다. 원본 BMP가 대부분 00과 ff라서 부호 확장의 영향을 덜 받았을 뿐, 사진이 조금만 복잡했으면 알아보기 어려웠을 것이다. 헤더가 BM인지, 왕복 해시가 맞는지 — 기계가 판정할 수 있는 기준을 하나는 두는 게 맞다.
🧰 재현 환경
| 항목 | 값 |
|---|---|
| OS | Ubuntu 25.10 (x86-64) |
| glibc | 2.42 — ctypes로 부르는 rand()가 이것이다 |
| Python | 3.13.7 (표준 ctypes만 사용, 이미지 렌더링에만 Pillow 10.4.0) |
| binutils | objdump 2.45 |
| gdb | 16.3 (-batch 모드) |
| 배포물 sha256 | flag.bmp.enc → 83266dde8aea8e67db2e97af066a843f263604b2cde6be0c16317d53d78a02f3 |
작업 폴더 구성은 이렇다. 디스어셈블 캡쳐에 붙인 주석은 sed 규칙 파일로 남겨 뒀다.
photographer/
├── extracted/ # 배포물 (prob, flag.bmp.enc)
├── solve.py # 복호 — flag.bmp 생성
├── toy_signed.py # 삽질 2 재현 (부호 확장 버전)
├── render.py # flag.bmp → 블로그용 PNG
├── verify.gdb # gdb 런타임 확인 스크립트
├── annot_*.sed # objdump 출력 주석 규칙
└── roundtrip/ # prob 을 오라클로 쓰는 왕복 검증ctypes로 libc rand()를 부르는 방식은 glibc 계열이면 어디서든 같은 수열이 나온다. musl(Alpine)은 rand() 구현이 달라 값이 어긋나니, 컨테이너로 돌린다면 데비안·우분투 계열 이미지를 쓰는 게 맞다.
📝 결론
시드가 상수면 난수는 난수가 아니다.
rand()가 보인다고 겁먹을 게 아니라 srand()에 뭐가 들어가는지부터 본다. time(NULL)이면 시각을 브루트포스해야 하지만, 상수면 그냥 같은 수열을 다시 뽑으면 끝이다. 이 문제는 time@plt가 없다는 것만으로 PLT 목록 단계에서 이미 답의 절반이 나왔다.
난수 소비 횟수가 분기마다 같은지 확인한다.
세 갈래 중 하나라도 rand()를 두 번 부르거나 건너뛰었다면 인덱스와 난수의 대응이 깨져서, 소비 순서를 따로 시뮬레이션해야 했다. 분기마다 정확히 한 번씩이라 for 한 줄로 끝났다. 역산 스크립트를 짜기 전에 이걸 먼저 세어 보는 게 순서다.
어셈블리를 고급 언어로 옮길 때는 폭과 부호를 같이 옮긴다.
sar만 보고 부호 확장을 넣으면 어긋나고, 앞의 movzx까지 봐야 영 확장인 걸 안다. 저장이 mov BYTE PTR이면 상위 비트는 버려진다는 것도 마찬가지다. 시프트·산술을 옮길 때 제일 자주 새는 곳이다.
복원물은 눈이 아니라 기계로 판정한다.
부호 확장 버전도 플래그는 읽혔다. 눈으로 통과시켰으면 틀린 코드를 맞다고 믿은 채 넘어갔을 것이다. 여기선 문제 바이너리 자체가 오라클이 되어 준다 — 복원본을 다시 넣어 배포본과 해시가 같은지 보면 논쟁의 여지가 없다. 일방향 변환기를 주는 리버싱 문제라면 대체로 쓸 수 있는 방법이다.
방어 관점에서는 이 구조 전체가 암호가 아니다.
키가 바이너리 안에 상수로 박혀 있고, 변환이 바이트 단위로 가역이며, 변환기 자체를 함께 배포한다. 셋 중 하나만 있어도 기밀성은 없다. 파일을 실제로 보호할 생각이라면 검증된 AEAD(AES-GCM, ChaCha20-Poly1305)에 외부에서 주입한 키를 쓰는 것 말고 다른 답은 없다. 직접 만든 바이트 변환은 난독화지 암호가 아니다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…