문제: 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
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으로 나눈 나머지를 테이블 인덱스로 쓰는 것뿐이다.

🔬 코드 정찰 — 1099줄 중 1024줄이 미끼
더미 선언을 빼고 나면 남는 게 몇 줄 안 된다. function 선언과 두 테이블, 그리고 마지막 비교식만 뽑아 봤다.
grep -n "function \|TABLE1 =\|TABLE2 =\|keccak256" challenge.sol | grep -v dummy
진짜 코드는 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로, '9'는 0'A'15로 접힌다. 16진 문자를 그 값으로 읽는 것과 정확히 같다. 범위를 벗어나면 'F'는 10BOOOOM!으로 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}"
셋 다 파이썬으로 계산한 값과 같고, 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
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개로 줄어든다](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-dreamthereum-writeup%2F06_diag_chain.png&w=3840&q=75)
게다가 두 글자 모두 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 문자 하나다](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-dreamthereum-writeup%2F07_table.png&w=3840&q=75)
(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
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
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
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
첫 호출이 Challenge::dummyA(0) → Return 67이다. 67은 0x43, 곧 'C'다. 앞에서 표로 뽑은 f('A','B') = 'C'가 EVM 안에서 그대로 재현된다. 트레이스 끝의 ← [Return] true가 flagChecker의 반환값이다.
마지막 관문인 keccak256도 컨트랙트 주석에 적힌 그 온라인 도구로 한 번 더 확인했다. 출제자가 challenge.sol 1082행에 URL을 남겨 뒀다.

♻️ 재현
문제 폴더만 있으면 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
로컬 환경: 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
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…