[🥈 Silver 4] 암호문 배열을 걷어내니 로직은 열 줄도 안 됐다 — DreamHack Secure Mail 풀이 — ZINO
2026-07-07·1분 읽기·
[🥈 Silver 4] 암호문 배열을 걷어내니 로직은 열 줄도 안 됐다 — DreamHack Secure Mail 풀이
1.1MB짜리 난독화된 JS라 겁먹었지만, 진짜 로직은 223KB짜리 암호문 배열을 걷어내면 열 줄이 안 됐다. 생년월일 후보의 MD5를 키/IV로 쓰는 AES-128-CBC로 첨부 메일을 복호화하는 구조라, 원본 코드를 그대로 브루트포스하면 한 번에 2.3초씩 걸려 하루가 넘게 걸린다. 알고리즘만 뽑아 Node 네이티브 AES로 다시 짜서 3만 5천 개 생년월일을 45초에 끝냈다.
받는 파일은 secure-mail.html 하나. 열어보면 봉투 아이콘 하나에 비밀번호 입력칸, Confirm 버튼이 전부다. placeholder에 Input your birthday eg.) 850810이라고 대놓고 적혀 있으니 6자리 생년월일이라는 건 바로 안다. 문제는 그 6자리를 확인하는 코드가 1.1MB짜리 난독화 덩어리라는 것.
두 파일 다 "with very long lines"라고 뜬다. 실제로 열어보면 줄바꿈이 거의 없는 한 줄짜리 코드고, 시작부터 var _0x2297=['W7OkEmokW5OKfGar', ...]처럼 의미 없는 문자열 배열이 수천 개 나열된다. 전형적인 javascript-obfuscator 산출물이다. 문자열은 전부 이 배열에 인코딩해 두고, 실행 시점에 디코더 함수로 하나씩 꺼내 쓰는 방식이라 grep으로 힌트를 찾는 게 거의 불가능하다.
deobfuscate.js 실행 — 함수 소스 1,104,718바이트 중 1,104,085바이트가 암호문 배열, 실제 로직은 689바이트
함수 전체 길이는 110만 바이트가 넘는데, 그중 file=[0x68, 0xda, ...] 배열 리터럴 하나가 110만 바이트를 다 잡아먹는다. 그 배열을 들어내고 나면 실제 로직은 689바이트 — 변수명만 지저분할 뿐 알고리즘 자체는 단순하다. 정리하면 이렇다.
function _0x9a220(password) { var key = Array.from(MD5(password, null, raw=true)).map(c => c.charCodeAt()), cipher = new ModeOfOperation.cbc(key, key); // key와 iv가 같은 값 var decrypted = cipher.decrypt(file); // file = 223,616바이트 암호문 var plaintext = ''; for (var i = 0; i < decrypted.length; i++) plaintext += String.fromCharCode(decrypted[i]); if (MD5(plaintext, null, raw=true) != FIXED_HASH) { return alert('Wrong'), false; } return document.write('<img src="' + plaintext + '">'), true;}
핵심은 세 가지다.
키 유도: 후보 비밀번호의 MD5 다이제스트(raw 16바이트)를 그대로 AES 키로 쓴다. 게다가 IV도 같은 값을 재사용한다 — 실무라면 바로 지적받을 안티패턴이지만, 여기선 그냥 "정답 비밀번호 하나만 찾으면 되는" 문제라 공격 표면이 되진 않는다.
암호 라이브러리: new _0x58829a['_0x14c3a3'][decoded_name](key, key) 형태로, Object.keys()로 열어보면 하위에 ecb / cbc / cfb / ofb / ctr 다섯 개 모드가 그대로 나온다. aes-js 라이브러리의 ModeOfOperation 객체와 이름 순서까지 정확히 일치한다.
검증 방식: 복호화한 문자열의 MD5를 다시 구해 고정값과 비교한다. 패딩 오라클도 없고, 복호화 자체는 항상 "성공"한다 — 그냥 나온 바이트열이 말이 되는지를 해시로 한 번 더 체크하는 구조다.
verify_md5.js 실행 — Node crypto MD5와 4개 테스트 문자열 전부 일치
네 개 테스트 문자열 전부 Node의 표준 MD5와 정확히 일치한다. raw=true 모드로 호출하면 16바이트 바이너리를 그대로 돌려주는 것도 동일하게 확인했다(blueimp-md5 계열 구현). 전체 흐름을 그림으로 정리하면 이렇다.
_0x9a220 검증 흐름 — MD5로 키/IV를 만들고 AES-128-CBC로 복호화한 뒤 다시 MD5로 검증
🎯 풀이 — 네이티브 크립토로 다시 짜기
느렸던 이유가 명확해졌다. 원본 코드를 eval로 그대로 돌리면 aes-js의 순수 JS 구현이 매번 콜드 스타트로 223KB를 암호화 없이 복호화하는데, V8 인터프리터가 JIT 웜업도 못 한 상태로 이 전체를 매번 새로 해석한다. 실측으로 비교해보면 차이가 확실하다 — 같은 후보로 aes-js 복호화는 약 39ms, Node 내장 crypto(OpenSSL, AES-NI 가속)는 약 0.8ms. 대략 50배 차이다.
그래서 알고리즘만 뽑아 Node 표준 라이브러리로 재구현했다. 키 유도(MD5)는 이미 crypto.createHash('md5')로 검증했고, 남은 건 AES-128-CBC 복호화뿐이다. 패딩이 없다는 것도 알고 있으니 setAutoPadding(false)만 챙기면 된다.
plainStr을 만드는 부분에서 한 가지만 신경 썼다. 원본 코드는 복호화된 바이트를 String.fromCharCode로 한 글자씩 이어붙인 뒤 그 문자열을 MD5로 해시하는데, JS 엔진 내부적으로 이 해시 함수가 문자열을 다시 UTF-8로 인코딩한다. 그래서 바이트 0x80 이상 값은 2바이트로 부풀려진다. latin1로 문자열을 만든 뒤 utf8로 재인코딩해 그 과정을 그대로 흉내냈다 — 실제로 임의 바이트로 여러 번 비교해 원본 함수와 결과가 100% 같다는 걸 미리 확인한 뒤 브루트포스에 들어갔다.
node fast_brute.js
fast_brute.js 실행 — 35,772번째 시도(1996-02-29)에서 45.4초 만에 적중
45.4초 만에 960229를 찾았다. 1996년 2월 29일 — 윤년의 2월 29일이라 달력에 실제로 존재하는 날짜다. 코드가 날짜 유효성 검사를 하지 않으니 2월 30일 같은 값도 그냥 시도되지만, 어차피 복호화 자체는 항상 "성공"하고 해시 비교에서만 걸러지므로 상관없다.
남은 건 복호화된 문자열을 이미지로 꺼내는 것뿐이다.
// extract_flag.jsconst fs = require('fs');const data = fs.readFileSync(__dirname + '/plaintext_output.txt', 'latin1');const m = data.match(/^data:image\/png;base64,(.*)$/s);const buf = Buffer.from(m[1], 'base64');fs.writeFileSync(__dirname + '/flag.png', buf);console.log('decrypted data URI ->', buf.length, 'byte PNG saved to flag.png');
node extract_flag.js
extract_flag.js 실행 — 167,691바이트 PNG 추출
복호화 결과는 data:image/png;base64,...로 시작하는 데이터 URI였다. base64를 그대로 디코드하면 167,691바이트짜리 PNG가 나온다.
브라우저로 직접 확인
찾은 비밀번호가 맞는지는 실제 페이지에 넣어봐도 확인할 수 있다. 틀린 값을 넣으면 alert('Wrong')이 뜬다.
잘못된 비밀번호 — alert('Wrong')
960229를 넣으면 document.write가 페이지를 통째로 갈아엎고 그 안에 플래그가 적힌 이미지가 뜬다.
올바른 비밀번호 — 페이지가 통째로 플래그 이미지로 교체된다
📝 결론
정적분석이 브루트포스보다 먼저다.
원본 스크립트를 그대로 돌려 브루트포스하는 건 후보 공간이 3만 7천 개 남짓으로 작은데도 하루 넘게 걸릴 만큼 비효율적이었다. 코드를 한 번 뜯어봤더니 진짜 로직은 689바이트, 알고리즘은 MD5 하나와 AES-CBC 하나뿐이었다. 알고리즘만 확보하면 어떤 언어의 표준 라이브러리로 재구현하든 상관없고, 이번처럼 네이티브 구현(OpenSSL)을 쓸 수 있으면 순수 JS 구현보다 수십 배 빨라진다.
난독화는 로직을 숨기지 못한다.
javascript-obfuscator는 변수명을 지우고 문자열을 인코딩하고 제어 흐름을 평탄화하지만, 함수를 실제로 실행할 수 있는 런타임(여기선 Node의 eval)만 있으면 .toString()으로 완성된 함수 소스를 그대로 뽑아낼 수 있다. 이번 문제처럼 거대한 상수 배열이 소스 크기 대부분을 차지하는 경우, 그 배열만 골라내면 난독화 전 로직이 거의 그대로 드러난다.
키와 IV를 같은 값으로 쓰지 않는다.
이 문제에서는 공격 표면이 되지 않았지만, AES-CBC에서 key와 iv를 같은 값으로 재사용하는 건 일반적으로 피해야 할 안티패턴이다. IV가 예측 가능해지면 같은 평문 블록이 같은 암호문 블록으로 나오는 등 CBC 모드가 기대하는 성질이 깨진다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…