[💠 Platinum 4] 1099줄짜리 컨트랙트에서 진짜 코드는 40줄 — DreamHack dreamthereum 풀이

2026-08-17·1분 읽기·

[💠 Platinum 4] 1099줄짜리 컨트랙트에서 진짜 코드는 40줄 — DreamHack dreamthereum 풀이

드림핵 dreamthereum(플래티넘 4, 리버싱)은 256개의 더미 함수로 채운 스마트컨트랙트다. flagChecker가 flag[i+2] = f(flag[i], flag[i+1])을 강제하는 사슬 구조라 앞 두 글자만 정하면 31글자가 전부 따라 나오고, f는 "dummy<A>(<타입>)" 시그니처의 keccak256 앞 4바이트를 16으로 나눈 나머지였다. 후보 256개를 전수로 돌려 flag를 복원하고 foundry로 띄운 실제 EVM에서 flagChecker()가 true를 반환하는 것까지 확인했다.

문제: DreamHack — dreamthereum 분류: reversing (스마트컨트랙트) 난이도: 💠 Platinum 4 FLAG: DH{ABCB69B964FA423A7BCB69B964FA423}

문제 설명은 짧다. "드림더리움의 세상에서는 스마트컨트랙트 배포 사이즈 제한도 실행할 gas limit도 없다"면서, 배포된 컨트랙트의 flagChecker 메소드가 true를 돌려주는 입력을 찾으라고 한다. 참고 링크로 4byte.directory를 걸어 놨다. 함수 셀렉터 이야기라는 뜻이다.

받은 파일은 challenge.sol 하나. 1099줄이다.

file challenge.sol; wc -l challenge.sol; grep -c "function dummy" challenge.sol; grep -oE "dummy[0-9A-F]\(" challenge.sol | sort -u | tr "\n" " "; echo
challenge.sol 정찰 — file/wc/grep으로 확인한 결과. 1099줄 ASCII 소스에 function dummy 선언이 256개 들어 있고, 함수 이름은 dummy0부터 dummyF까지 16종류뿐이다
challenge.sol 정찰 — file/wc/grep으로 확인한 결과. 1099줄 ASCII 소스에 function dummy 선언이 256개 들어 있고, 함수 이름은 dummy0부터 dummyF까지 16종류뿐이다

function dummy가 256개. 이름은 dummy0~dummyF 16개뿐인데 선언이 256개라는 건, 이름 하나당 16개씩 오버로드돼 있다는 뜻이다. 배포 사이즈 제한이 없다는 설명이 여기서 걸린다 — 실제 이더리움이라면 EIP-170의 24KB 코드 제한에 걸려 배포 자체가 안 된다.


문제 개요

항목내용
문제명dreamthereum
난이도💠 Platinum 4
분류reversing (EVM / Solidity)
제공 파일challenge.sol (1099줄, 서버 없음)
스택Solidity >=0.7.0 <0.9.0, EVM
핵심 기법함수 셀렉터(keccak256 앞 4바이트)를 값으로 쓰는 상태 전이 + 사슬 제약으로 탐색 공간을 256으로 축소

풀이 흐름은 이렇다. 256개 더미 함수는 전부 TABLE2[uint32(msg.sig) % TABLE2.length]만 반환하는 껍데기다. flagChecker는 입력 31바이트를 훑으면서 internalCall(flag[i], flag[i+1])의 결과가 flag[i+2]와 같은지 계속 확인한다. 즉 앞 두 글자를 고르면 나머지 29글자는 계산으로 정해진다. 후보는 16×16 = 256개뿐이고, 마지막에 걸린 keccak256 비교가 그중 하나만 남긴다.



🧩 배경 — EVM 함수 셀렉터

이더리움에서 컨트랙트를 호출할 때 calldata 맨 앞 4바이트가 함수 셀렉터다. 값은 함수 시그니처 문자열의 keccak256 해시 앞 4바이트다.

selector = keccak256("transfer(address,uint256)")[0:4] = 0xa9059cbb

시그니처에 들어가는 건 함수 이름과 인자 타입뿐이다. 인자 이름도, 반환 타입도, view 같은 수식자도 들어가지 않는다. 그래서 같은 이름을 타입만 바꿔 오버로드하면 셀렉터가 전부 달라진다. 4byte.directory는 이 4바이트를 원래 시그니처로 되돌려 보는 공개 DB다.

이 문제는 그 셀렉터를 "주소"가 아니라 값으로 쓴다. 더미 함수가 하는 일은 자기를 부른 셀렉터를 32비트 정수로 읽어 16으로 나눈 나머지를 테이블 인덱스로 쓰는 것뿐이다.

internalCall 도식 — A와 B 두 글자로 dummy 시그니처 문자열을 조립하고, keccak256 앞 4바이트를 uint32로 읽어 16으로 나눈 나머지로 TABLE2를 인덱싱해 다시 16진 문자 하나를 얻는 경로
internalCall 도식 — A와 B 두 글자로 dummy 시그니처 문자열을 조립하고, keccak256 앞 4바이트를 uint32로 읽어 16으로 나눈 나머지로 TABLE2를 인덱싱해 다시 16진 문자 하나를 얻는 경로

🔬 코드 정찰 — 1099줄 중 1024줄이 미끼

더미 선언을 빼고 나면 남는 게 몇 줄 안 된다. function 선언과 두 테이블, 그리고 마지막 비교식만 뽑아 봤다.

grep -n "function \|TABLE1 =\|TABLE2 =\|keccak256" challenge.sol | grep -v dummy
grep으로 추린 challenge.sol의 비-더미 코드 — TABLE1과 TABLE2 선언, sliceUint/concatenate/getArgsFromB/flagChecker/internalCall 다섯 함수, 그리고 1083행의 keccak256 비교 상수 0xe6a9e5bc가 한 화면에 보인다
grep으로 추린 challenge.sol의 비-더미 코드 — TABLE1과 TABLE2 선언, sliceUint/concatenate/getArgsFromB/flagChecker/internalCall 다섯 함수, 그리고 1083행의 keccak256 비교 상수 0xe6a9e5bc가 한 화면에 보인다

진짜 코드는 40줄 남짓이다. 두 테이블부터 보자.

string[] TABLE1 = ["uint256", "bytes", "bytes4", "bytes1", "bytes32", "uint8",
                   "uint16", "uint248", "string", "uint240", "uint224", "uint192",
                   "uint24", "uint32", "uint56", "uint72"];
uint8[] TABLE2 = [0x41,0x42,0x43,0x44,0x45,0x46,0x30,0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39];

TABLE1은 16개 타입 이름, TABLE2는 'A''F' 다음에 '0''9'가 오는 16개 문자다. 둘 다 16진 문자 하나로 귀결된다는 게 포인트다. 순서에 주의해야 한다. TABLE2[0]은 '0'이 아니라 'A'다.

더미 함수 256개는 전부 같은 몸통을 갖는다.

function dummyA(uint192) external view returns (uint8) {
    return TABLE2[uint32(msg.sig) % TABLE2.length];
}

인자는 이름조차 없다. 값을 쓰지 않기 때문이다. 이 함수가 반환하는 값은 어떤 셀렉터로 불렸는가에만 의존한다.

핵심 세 함수

function getArgsFromB(uint8 x) internal view returns (string memory) {
    require(x >= 0x41 && x < 0x41+6 || x >= 0x30 && x <= 0x39, "BOOOOM!");
    if (x>=0x41)
        return TABLE1[x-0x41 + 0xa];
    return TABLE1[x-0x30];
}

'0''9'는 09로, 'A''F'는 1015로 접힌다. 16진 문자를 그 값으로 읽는 것과 정확히 같다. 범위를 벗어나면 BOOOOM!으로 revert한다.

function internalCall(uint8 A, uint8 B) internal onlyOwner returns (uint8) {
     string memory callMethod = "dummy";
     callMethod = concatenate(callMethod, A);              // 이름 한 글자
     callMethod = concatenate(callMethod, "(");
     callMethod = concatenate(callMethod, getArgsFromB(B)); // 인자 타입
     callMethod = concatenate(callMethod, ")");
 
    (bool success, bytes memory returnData) = address(this).call(abi.encodeWithSignature(callMethod, 0x0));
    require(success, "Bamb!");
 
    return uint8(sliceUint(returnData, 0));
}

문자열로 시그니처를 조립해 abi.encodeWithSignature로 넘긴다. 이때 계산되는 셀렉터가 곧 그 더미 함수의 msg.sig가 되고, 더미는 그걸 16으로 나눈 나머지로 TABLE2를 찍어 돌려준다. 그러니까 internalCall(A, B)는 문자 두 개를 받아 문자 하나를 뱉는 함수다. 컨트랙트 안에서 벌어지는 자기 호출이지만, 결과를 결정하는 건 keccak256뿐이라 오프라인에서 그대로 계산할 수 있다.

function flagChecker(bytes calldata flag) external returns (bool) {
    uint8 ret;
    bytes memory output = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; // output.length == 31
 
    for(uint i=0; i<output.length - 2; i++) {
        ret = internalCall(uint8(flag[i]), uint8(flag[i+1]));
        output[i] = flag[i];
        output[i+1] = flag[i+1];
        output[i+2] = bytes1(ret);
        require(ret == uint8(flag[i+2]), "BOBOBOBO!!!");
    }
 
    // https://emn178.github.io/online-tools/keccak_256.html
    return keccak256(abi.encodePacked("DH{", output, "}")) == bytes32(0xe6a9e5bcd806b56f7ab007dbb1e881f5b5746f00cb940b8083be861a257f7806);
}

output은 31바이트고 루프는 i = 0..28을 돈다. 매 회 flag[i+2]를 읽으므로 입력은 최소 31바이트여야 하고, 루프가 끝나면 output은 flag의 앞 31바이트 그 자체가 된다.


💣 핵심 — 셀렉터가 곧 전이함수다

internalCall을 파이썬으로 옮기면 이렇게 된다.

f(A, B) = TABLE2[ uint32(keccak256("dummy" + A + "(" + TABLE1[hex(B)] + ")")[:4]) % 16 ]

여기서 내 계산이 맞는지부터 확인해야 한다. 셀렉터는 직접 keccak을 돌려도 되지만, foundry의 cast가 같은 일을 해 주니 교차검증으로 쓰기 좋다. cast keccak으로 최종 비교 대상까지 한 번에 확인했다.

for s in "dummyA(uint192)" "dummy0(uint72)" "dummyD(bytes1)"; do printf "%-18s -> " "$s"; docker run --rm -e FOUNDRY_DISABLE_NIGHTLY_WARNING=1 --entrypoint cast ghcr.io/foundry-rs/foundry:latest sig "$s"; done; docker run --rm -e FOUNDRY_DISABLE_NIGHTLY_WARNING=1 --entrypoint cast ghcr.io/foundry-rs/foundry:latest keccak "DH{ABCB69B964FA423A7BCB69B964FA423}"
foundry cast로 셀렉터와 해시를 교차검증한 터미널 화면 — dummyA(uint192)는 0xab128832, dummy0(uint72)는 0xbbda911b, dummyD(bytes1)는 0x4a68a17d이고 cast keccak이 찍은 플래그 해시가 컨트랙트 상수 0xe6a9e5bc…7806과 같다
foundry cast로 셀렉터와 해시를 교차검증한 터미널 화면 — dummyA(uint192)는 0xab128832, dummy0(uint72)는 0xbbda911b, dummyD(bytes1)는 0x4a68a17d이고 cast keccak이 찍은 플래그 해시가 컨트랙트 상수 0xe6a9e5bc…7806과 같다

셋 다 파이썬으로 계산한 값과 같고, cast keccak이 뱉은 해시는 소스 1083행의 상수와 정확히 일치한다.

컴파일러가 실제로 만든 ABI에서도 같은 값이 나오는지 확인했다. forge inspect로 메소드 표를 뽑으면 컨트랙트가 가진 셀렉터가 전부 나온다.

docker run --rm -e FOUNDRY_DISABLE_NIGHTLY_WARNING=1 -v "$PWD":/w -w /w --entrypoint forge ghcr.io/foundry-rs/foundry:latest inspect src/Challenge.sol:Challenge methods | tee methods.txt | grep -E "dummyA\(uint192\)|dummy0\(uint72\)|dummyD\(bytes1\)|flagChecker"; grep -c "| dummy" methods.txt
forge inspect가 뽑은 Challenge 컨트랙트의 메소드 셀렉터 표 — dummy0(uint72) bbda911b, dummyA(uint192) ab128832, dummyD(bytes1) 4a68a17d, flagChecker(bytes) 6f085333이 보이고 dummy 계열 메소드가 256개로 집계된다
forge inspect가 뽑은 Challenge 컨트랙트의 메소드 셀렉터 표 — dummy0(uint72) bbda911b, dummyA(uint192) ab128832, dummyD(bytes1) 4a68a17d, flagChecker(bytes) 6f085333이 보이고 dummy 계열 메소드가 256개로 집계된다

dummy 메소드가 256개, 셀렉터도 그대로다. 여기까지 오면 f는 순수한 표 하나로 확정된다.



🎯 사슬 구조 — 앞 두 글자가 전부다

flagChecker의 require를 다시 보자.

require(ret == uint8(flag[i+2]), "BOBOBOBO!!!");

ret은 f(flag[i], flag[i+1])이다. 그러니 이 조건은 곧 flag[i+2] = f(flag[i], flag[i+1])이라는 점화식이다. i가 0부터 28까지 도니까 flag[2]부터 flag[30]까지 29글자가 앞 두 글자에서 연쇄적으로 결정된다.

flagChecker의 사슬 구조 도식 — 폭 2의 창이 한 칸씩 미끄러지며 f(flag[i], flag[i+1])이 flag[i+2]를 확정하고, 자유로운 건 맨 앞 두 글자뿐이라 후보가 16×16 = 256개로 줄어든다
flagChecker의 사슬 구조 도식 — 폭 2의 창이 한 칸씩 미끄러지며 f(flag[i], flag[i+1])이 flag[i+2]를 확정하고, 자유로운 건 맨 앞 두 글자뿐이라 후보가 16×16 = 256개로 줄어든다

게다가 두 글자 모두 16진 문자여야 한다. B 자리는 getArgsFromB가 BOOOOM!으로 막고, A 자리는 존재하지 않는 이름이면 call이 실패해 Bamb!로 막힌다. 그리고 f의 출력은 TABLE2 원소라 역시 16진 문자다. 결국 31글자 전부가 0-9A-F다.

전이표를 실제로 뽑아 봤다. 행이 함수 이름 A, 열이 인자 타입 B다.

#!/usr/bin/env python3
"""internalCall(A,B) 전이표를 16x16 으로 출력한다. solve.py 와 같은 로직."""
from solve import ALPHABET, TABLE1, TABLE2, hexval, selector
 
print("  예시 시그니처 → 셀렉터 → %16 → 반환문자")
for a, b in (("A", "B"), ("0", "F"), ("D", "3")):
    sig = "dummy%s(%s)" % (a, TABLE1[hexval(b)])
    sel = selector(sig)
    print("    %-22s 0x%08x  %%16=%2d  -> '%s'"
          % (sig, sel, sel % 16, chr(TABLE2[sel % 16])))
 
print()
print("  f(A,B) 전이표   (행=A: 함수이름, 열=B: 인자타입)")
print("      " + "  ".join(ALPHABET))
for a in ALPHABET:
    row = []
    for b in ALPHABET:
        sig = "dummy%s(%s)" % (a, TABLE1[hexval(b)])
        row.append(chr(TABLE2[selector(sig) % 16]))
    print("   %s  %s" % (a, "  ".join(row)))
python3 table.py
table.py 실행 결과 — dummyA(uint192)가 0xab128832라서 %16=2이고 TABLE2[2]='C'가 되는 예시 세 줄과, 16×16 전이표 전체가 출력됐다. 표의 모든 칸이 0-9A-F 문자 하나다
table.py 실행 결과 — dummyA(uint192)가 0xab128832라서 %16=2이고 TABLE2[2]='C'가 되는 예시 세 줄과, 16×16 전이표 전체가 출력됐다. 표의 모든 칸이 0-9A-F 문자 하나다

(A, B) = ('A', 'B')면 'C'. 실제 flag가 ABC로 시작하는 이유가 이 한 칸에 들어 있다.

▶🐛 함정 — 읽기 쉬운 오독 세 가지, 증상은 전부 '후보 0개'

이 문제는 중간 피드백이 없다. require가 and 누산도 아니고 부분 점수도 없어서, 모델을 틀리게 세우면 256개를 다 돌려도 아무것도 안 걸린다. 반대로 말하면 후보가 정확히 1개 나오는지가 모델 검증이다. 헷갈리기 쉬운 세 지점을 일부러 틀리게 구현해 돌려 봤다.

#!/usr/bin/env python3
"""읽기 쉬운 오독 세 가지를 실제로 돌려 본다. 전부 후보 0개가 나온다.
 
  A) TABLE2 를 '자연스러운' 16진 순서 '0'..'9','A'..'F' 로 읽는 경우
  B) 셀렉터를 리틀엔디언 uint32 로 읽는 경우
  C) 검사 대상 문자열 길이를 31 이 아니라 32 로 잡는 경우
"""
from solve import ALPHABET, TABLE1, TABLE2, TARGET, hexval, k256, selector
 
NATURAL = [ord(c) for c in "0123456789ABCDEF"]      # 오독 A 용
 
 
def count_hits(table2, endian="big", length=31):
    tbl = {}
    for a in ALPHABET:
        for b in ALPHABET:
            sig = "dummy%s(%s)" % (a, TABLE1[hexval(b)])
            raw = k256(sig.encode())[:4]
            sel = int.from_bytes(raw, endian)
            tbl[(a, b)] = chr(table2[sel % 16])
    hits = []
    for c0 in ALPHABET:
        for c1 in ALPHABET:
            s = [c0, c1]
            while len(s) < length:
                s.append(tbl[(s[-2], s[-1])])
            out = "".join(s)
            if int.from_bytes(k256(b"DH{" + out.encode() + b"}"), "big") == TARGET:
                hits.append(out)
    return hits
 
 
for label, kw in (
    ("A) TABLE2 를 '0'..'9','A'..'F' 순서로 오독", dict(table2=NATURAL)),
    ("B) 셀렉터를 리틀엔디언으로 오독      ", dict(table2=TABLE2, endian="little")),
    ("C) 길이를 32 로 오독                 ", dict(table2=TABLE2, length=32)),
    ("정답 모델                            ", dict(table2=TABLE2)),
):
    hits = count_hits(**kw)
    print("  %s -> 후보 %d개 %s" % (label, len(hits), ("DH{%s}" % hits[0]) if hits else ""))
python3 pitfall.py
pitfall.py 실행 결과 — TABLE2 순서 오독, 셀렉터 리틀엔디언 오독, 길이 32 오독 세 경우는 모두 후보 0개이고 정답 모델만 후보 1개로 플래그 본문 ABCB69B964FA423A7BCB69B964FA423 을 뽑는다
pitfall.py 실행 결과 — TABLE2 순서 오독, 셀렉터 리틀엔디언 오독, 길이 32 오독 세 경우는 모두 후보 0개이고 정답 모델만 후보 1개로 플래그 본문 ABCB69B964FA423A7BCB69B964FA423 을 뽑는다

TABLE2는 'A'부터 시작하니 '0'..'9','A'..'F' 순서로 읽으면 어긋난다. bytes4를 uint32로 바꾸는 Solidity 변환은 비트 표현을 그대로 유지하므로 빅엔디언이고, 리틀엔디언으로 읽으면 나머지가 달라진다. 검사 대상은 입력 전체가 아니라 31바이트짜리 output이다. 셋 다 증상이 똑같이 "아무것도 안 나옴"이라, 어디가 틀렸는지 알려 주는 신호가 없다.


🚀 Full Exploit

전수라고 해 봐야 256개다. 각 시작쌍으로 31글자를 전개하고 keccak256("DH{" + s + "}")를 목표 상수와 비교한다.

#!/usr/bin/env python3
"""dreamthereum (Dreamhack, Platinum 4 / reversing) — solver
 
challenge.sol 의 flagChecker 는 flag 를 31바이트로 읽으면서
    flag[i+2] == internalCall(flag[i], flag[i+1])   (i = 0 .. 28)
을 강제한다. internalCall 은 "dummy<A>(<TABLE1[hex(B)]>)" 라는 시그니처를 문자열로
조립해 자기 자신을 call 하고, 불린 더미 함수는 TABLE2[uint32(msg.sig) % 16] 을 돌려준다.
 
즉 f(A,B) = TABLE2[ uint32(keccak256("dummy"+A+"("+TABLE1[hex(B)]+")")[:4]) % 16 ] 이고,
31글자 전체가 앞 두 글자만으로 결정된다 → 후보는 16*16 = 256개뿐.
마지막 관문 keccak256("DH{" + output + "}") == 0xe6a9...7806 로 하나를 고른다.
"""
from Crypto.Hash import keccak
 
TABLE1 = ["uint256", "bytes", "bytes4", "bytes1", "bytes32", "uint8",
          "uint16", "uint248", "string", "uint240", "uint224", "uint192",
          "uint24", "uint32", "uint56", "uint72"]
# 0x41..0x46 = 'A'..'F', 0x30..0x39 = '0'..'9'
TABLE2 = [0x41, 0x42, 0x43, 0x44, 0x45, 0x46,
          0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39]
 
ALPHABET = "0123456789ABCDEF"          # getArgsFromB 가 허용하는 문자 집합
TARGET = 0xe6a9e5bcd806b56f7ab007dbb1e881f5b5746f00cb940b8083be861a257f7806
 
 
def k256(data: bytes) -> bytes:
    h = keccak.new(digest_bits=256)
    h.update(data)
    return h.digest()
 
 
def hexval(ch: str) -> int:
    """getArgsFromB: '0'-'9' -> 0..9, 'A'-'F' -> 10..15"""
    o = ord(ch)
    if 0x41 <= o < 0x41 + 6:
        return o - 0x41 + 0xa
    if 0x30 <= o <= 0x39:
        return o - 0x30
    raise ValueError("BOOOOM!")
 
 
def selector(sig: str) -> int:
    """abi.encodeWithSignature 의 앞 4바이트 = uint32(msg.sig)"""
    return int.from_bytes(k256(sig.encode())[:4], "big")
 
 
def build_table():
    """f[A][B] = internalCall(A, B) 의 반환 문자"""
    table = {}
    for a in ALPHABET:
        for b in ALPHABET:
            sig = "dummy%s(%s)" % (a, TABLE1[hexval(b)])
            table[(a, b)] = chr(TABLE2[selector(sig) % len(TABLE2)])
    return table
 
 
def expand(c0: str, c1: str, table, length: int = 31) -> str:
    s = [c0, c1]
    while len(s) < length:
        s.append(table[(s[-2], s[-1])])
    return "".join(s)
 
 
def main():
    table = build_table()
 
    # 256개 시작쌍 전수
    hits = []
    for c0 in ALPHABET:
        for c1 in ALPHABET:
            out = expand(c0, c1, table)
            digest = k256(b"DH{" + out.encode() + b"}")
            if int.from_bytes(digest, "big") == TARGET:
                hits.append(out)
 
    for out in hits:
        flag = "DH{%s}" % out
        print("[+] output   =", out, "(len %d)" % len(out))
        print("[+] keccak   =", k256(flag.encode()).hex())
        print("[+] FLAG     =", flag)
 
    if not hits:
        print("[-] 후보 없음")
        return
 
    # 자체검산: 찾은 flag 를 flagChecker 로직 그대로 다시 돌려 본다
    out = hits[0]
    for i in range(len(out) - 2):
        assert table[(out[i], out[i + 1])] == out[i + 2], i
    print("[+] self-check: flag[i+2] == f(flag[i], flag[i+1]) for all i — OK")
 
 
if __name__ == "__main__":
    main()
python3 solve.py
solve.py 실행 결과 — 31글자 output ABCB69B964FA423A7BCB69B964FA423, 그 keccak 해시 e6a9e5bc…7806, 최종 FLAG, 그리고 사슬 조건을 모든 i에 대해 다시 검사한 self-check OK가 출력됐다
solve.py 실행 결과 — 31글자 output ABCB69B964FA423A7BCB69B964FA423, 그 keccak 해시 e6a9e5bc…7806, 최종 FLAG, 그리고 사슬 조건을 모든 i에 대해 다시 검사한 self-check OK가 출력됐다
DH{ABCB69B964FA423A7BCB69B964FA423}

복원된 문자열을 보면 A7 이후 BCB69B964FA423이 그대로 반복된다. 전이함수가 유한한 표라서 창이 이미 지나온 쌍을 다시 만나면 그때부터 주기에 갇힌다. 여기서는 주기 14로 순환했다.



✅ 실제 EVM 위에서 확인

계산으로 나온 값이니 컨트랙트가 진짜 true를 주는지도 봐야 한다. 문제 서버가 없는 유형이라 로컬에서 EVM을 띄웠다. foundry 이미지를 그대로 쓰면 solc 설치까지 알아서 한다.

여기서 걸리는 게 internalCall의 onlyOwner다. owner는 생성자에서 msg.sender로 박히므로, 테스트 컨트랙트가 직접 Challenge를 배포하면 그 테스트 컨트랙트가 owner가 된다. forge-std 없이 순수 Solidity만으로 짰다.

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
 
import "../src/Challenge.sol";
 
// forge-std 없이 도는 테스트. 이 컨트랙트가 Challenge 를 배포하므로 owner == 이 컨트랙트가 되어
// internalCall 의 onlyOwner 를 통과한다.
contract FlagTest {
    Challenge c;
 
    function setUp() public {
        c = new Challenge();
    }
 
    // 정답 flag → flagChecker 가 true
    function test_correct_flag_returns_true() public {
        bool ok = c.flagChecker(bytes("ABCB69B964FA423A7BCB69B964FA423"));
        require(ok, "flagChecker returned false");
    }
 
    // 마지막 한 글자만 바꿔도 i=28 의 require 에서 BOBOBOBO!!! 로 revert
    function test_last_byte_flipped_reverts_BOBOBOBO() public {
        try c.flagChecker(bytes("ABCB69B964FA423A7BCB69B964FA424")) returns (bool ok) {
            require(!ok, "tampered flag was accepted");
        } catch Error(string memory reason) {
            require(
                keccak256(bytes(reason)) == keccak256(bytes("BOBOBOBO!!!")),
                "unexpected revert reason"
            );
        }
    }
 
    // 16진 문자가 아닌 바이트가 인자타입 자리에 오면 getArgsFromB 가 BOOOOM!
    function test_non_hex_byte_reverts_BOOOOM() public {
        try c.flagChecker(bytes("AZCB69B964FA423A7BCB69B964FA423")) returns (bool ok) {
            require(!ok, "non-hex flag was accepted");
        } catch Error(string memory reason) {
            require(
                keccak256(bytes(reason)) == keccak256(bytes("BOOOOM!")),
                "unexpected revert reason"
            );
        }
    }
}
docker run --rm -e FOUNDRY_DISABLE_NIGHTLY_WARNING=1 -v "$PWD":/w -w /w --entrypoint forge ghcr.io/foundry-rs/foundry:latest test -vv
forge test 실행 결과 — solc 0.8.36으로 컴파일한 뒤 정답 flag가 true를 반환하는 테스트, 마지막 글자를 바꾸면 BOBOBOBO로 revert하는 테스트, 비-16진 바이트가 BOOOOM으로 revert하는 테스트 3개가 모두 PASS했다
forge test 실행 결과 — solc 0.8.36으로 컴파일한 뒤 정답 flag가 true를 반환하는 테스트, 마지막 글자를 바꾸면 BOBOBOBO로 revert하는 테스트, 비-16진 바이트가 BOOOOM으로 revert하는 테스트 3개가 모두 PASS했다

3개 전부 통과. 반환값과 실패 경로까지 실제 EVM에서 확인된 셈이다.

호출 트레이스를 보면 컨트랙트 안에서 무슨 일이 벌어지는지 그대로 나온다. -vvvv로 켜고, 가운데 반복 구간은 잘라서 봤다.

docker run --rm -e FOUNDRY_DISABLE_NIGHTLY_WARNING=1 -v "$PWD":/w -w /w --entrypoint forge ghcr.io/foundry-rs/foundry:latest test --match-test test_correct_flag_returns_true -vvvv > trace.txt 2>&1; sed -n "/^Traces:/,+13p" trace.txt; echo "    ...(중략: dummy 호출은 모두 29회)..."; tail -9 trace.txt
forge test -vvvv 호출 트레이스 — flagChecker 안에서 dummyA, dummyB, dummyC, dummyB, dummy6, dummy9가 차례로 호출되고 각각 67, 66, 54, 57, 66을 반환하며, 마지막에 flagChecker가 Return true로 끝난다
forge test -vvvv 호출 트레이스 — flagChecker 안에서 dummyA, dummyB, dummyC, dummyB, dummy6, dummy9가 차례로 호출되고 각각 67, 66, 54, 57, 66을 반환하며, 마지막에 flagChecker가 Return true로 끝난다

첫 호출이 Challenge::dummyA(0) → Return 67이다. 67은 0x43, 곧 'C'다. 앞에서 표로 뽑은 f('A','B') = 'C'가 EVM 안에서 그대로 재현된다. 트레이스 끝의 ← [Return] true가 flagChecker의 반환값이다.

마지막 관문인 keccak256도 컨트랙트 주석에 적힌 그 온라인 도구로 한 번 더 확인했다. 출제자가 challenge.sol 1082행에 URL을 남겨 뒀다.

emn178 Keccak-256 온라인 도구 화면 — 입력창에 DH 로 시작하는 플래그 문자열 ABCB69B964FA423A7BCB69B964FA423 을 넣자 출력이 e6a9e5bcd806b56f7ab007dbb1e881f5b5746f00cb940b8083be861a257f7806으로 컨트랙트 상수와 일치한다
emn178 Keccak-256 온라인 도구 화면 — 입력창에 DH 로 시작하는 플래그 문자열 ABCB69B964FA423A7BCB69B964FA423 을 넣자 출력이 e6a9e5bcd806b56f7ab007dbb1e881f5b5746f00cb940b8083be861a257f7806으로 컨트랙트 상수와 일치한다

♻️ 재현

문제 폴더만 있으면 flag까지 한 번에 나오게 스크립트를 남겼다. solve.py는 keccak만 있으면 되고(파이썬 pycryptodome), docker에 foundry 이미지가 있으면 EVM 검증까지 이어서 돈다.

#!/usr/bin/env bash
# dreamthereum (Dreamhack, Platinum 4 / reversing) — 한 방 재현.
#   solve.py 로 flag 를 복원하고, docker foundry 가 있으면 실제 EVM 으로 한 번 더 검증한다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
EXPECT=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null || echo '')
 
# 1) 오프라인 복원 — keccak256 만 있으면 된다 (pycryptodome)
OUT=$(timeout 300 python3 solve.py 2>&1) || true
echo "$OUT"
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | head -1)
 
# 2) (선택) 실제 EVM 검증 — foundry 이미지가 이미 받아져 있을 때만 돈다
if command -v docker >/dev/null 2>&1 && docker image inspect ghcr.io/foundry-rs/foundry:latest >/dev/null 2>&1; then
  echo
  echo "--- forge test (실제 EVM) ---"
  ( cd evm && timeout 600 docker run --rm -v "$PWD":/w -w /w \
      --entrypoint forge ghcr.io/foundry-rs/foundry:latest test -vv ) || true
  docker run --rm -v "$PWD/evm":/w alpine:latest rm -rf /w/out /w/cache >/dev/null 2>&1 || true
else
  echo
  echo "(docker/foundry 없음 — EVM 검증은 건너뛴다. solve.py 만으로 flag 는 나온다)"
fi
 
echo
if [ -n "$FLAG" ] && { [ -z "$EXPECT" ] || [ "$FLAG" = "$EXPECT" ]; }; then
  echo "✅ PASS  $FLAG"; exit 0
fi
echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '$EXPECT')"; exit 1
./reproduce.sh
reproduce.sh 실행 결과 — solve.py가 flag를 복원하고 이어서 forge test 3개가 모두 PASS한 뒤 마지막 줄에 PASS 와 플래그 본문 ABCB69B964FA423A7BCB69B964FA423 이 찍혔다
reproduce.sh 실행 결과 — solve.py가 flag를 복원하고 이어서 forge test 3개가 모두 PASS한 뒤 마지막 줄에 PASS 와 플래그 본문 ABCB69B964FA423A7BCB69B964FA423 이 찍혔다

로컬 환경: Ubuntu 6.17 커널 / Python 3.13 + pycryptodome / Docker 29.7.2 + ghcr.io/foundry-rs/foundry:latest(solc 0.8.36 자동 설치). challenge.sol은 evm/src/Challenge.sol로 그대로 복사해서 쓴다.



📝 결론

셀렉터는 주소가 아니라 값으로도 쓸 수 있다

이 문제의 재미는 keccak256 해시의 앞 4바이트를 난수표처럼 굴린 데 있다. 함수 이름과 인자 타입이 한 글자씩 대응되니 "문자 두 개 → 문자 하나"라는 전이가 만들어지고, 그 전이가 사슬로 엮이면서 31글자짜리 답이 앞 두 글자로 압축된다. 미끼 1024줄은 이 구조를 덮기 위한 껍데기다.

탐색 공간을 줄이는 건 제약 자체다

require가 매 회 다음 글자를 못 박는 순간 자유도는 두 글자로 떨어진다. 이런 문제에서 먼저 볼 것은 알고리즘의 복잡도가 아니라 입력의 어느 부분이 정말 자유로운가다. 반대로 부분 정답 신호가 전혀 없다는 점 때문에, 모델이 틀리면 후보 0개라는 결과만 남는다. 그래서 "후보가 정확히 1개"라는 사실이 곧 모델 검증이 된다.

온체인에 올라간 것에 비밀은 없다

flagChecker는 onlyOwner로 감싸여 있어 아무나 부를 수 없지만, 검증에 쓰이는 상수와 로직은 전부 코드 안에 있다. 컨트랙트 바이트코드는 누구나 읽을 수 있으니 접근 제어는 실행 권한을 막을 뿐 정보를 감추지 못한다. 답을 컨트랙트 안에서 계산할 수 있게 만들어 두면 그 계산은 오프라인에서도 똑같이 돌아간다.

오버로딩과 셀렉터 충돌

같은 이름을 16가지 타입으로 오버로드해도 셀렉터가 겹치지 않았기에 이 구조가 성립한다. 실제 서비스 컨트랙트라면 반대로 위험 신호다. 4바이트는 충돌이 얼마든지 만들어질 수 있는 공간이고, 프록시 패턴에서 셀렉터 충돌은 실제 사고로 이어진 적이 있다. 배포 전에 forge inspect <컨트랙트> methods로 셀렉터 표를 뽑아 두면 최소한 겹침은 눈으로 확인할 수 있다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 2] private 는 비밀이 아니다 — DreamHack Where is gift? 풀이
blog

[🥉 Bronze 2] private 는 비밀이 아니다 — DreamHack Where is gift? 풀이

Solidity 컨트랙트가 플래그를 `string private` 로 숨겼지만, EVM 스토리지는 온체인에 평문으로 남는다. Sepolia 테스트넷에 eth_getStorageAt 을 날려 slot 0 을 읽고, 긴 문자열 저장 규칙(keccak256 베이스 슬롯)으로 데이터를 복원해 플래그를 꺼낸다.
#dreamhack#ctf#misc+6
2026-10-12#dreamhack +4
[💠 Platinum 4] 입력-무관 "매직" 디코드의 정체 — DreamHack MagicVM 풀이
blog

[💠 Platinum 4] 입력-무관 "매직" 디코드의 정체 — DreamHack MagicVM 풀이

639KB 스트립 바이너리 안에 숨은 커스텀 스택 VM 리버싱. 각 바이트코드는 입력 3바이트를 받는 "매직" 함수로 복호되는 것처럼 보이지만, 256개 함수를 Unicorn 으로 전수 실측하면 전부 상수다 — 즉 디코드는 순수 난독화이고 VM 프로그램은 고정이다. 16개 핸들러를 파이썬으로 재구현해 프로그램을 평문으로 복원하고, reg[k] 가 input[k] 에만 의존한다는 성질로 바이트별 브루트포스하면 유일한 입력(=flag 본문)이 떨어진다.
#dreamhack#ctf#reversing+5
2026-10-16#dreamhack +3
[💠 Platinum 3] 신기루 뒤의 선형 연립방정식 — DreamHack Mirage 풀이
blog

[💠 Platinum 3] 신기루 뒤의 선형 연립방정식 — DreamHack Mirage 풀이

겉보기엔 한 글자씩 비교하는 평범한 검증 같지만, 그 관문은 size만 맞으면 통과하는 신기루였다. 진짜 검증은 입력 50자를 5자씩 10개의 big-endian 정수로 바꿔 base-128 bignum 곱셈으로 섞은 뒤 10×10 상수 행렬과 맞춰보는 선형 연립방정식이었다. gdb로 상수를 뽑아 가우스 소거로 풀었다.
#dreamhack#ctf#reversing+5
2026-10-11#dreamhack +3