문제

파일을 다운로드 받아 확인 시 난독화 되어 있음을 확인함
그리고 제출 버튼 클릭 시 사용자 입력값을 받아 _0x9a220() 함수가 실행되는 것을 확인함
<button onclick="_0x9a220(pass.value);">Confirm</button> 형태로, input의 값을 그대로 인자로 넘기는 구조임

보기 불편하니까 개발자도구 → Sources 창에서 _0x9a220 함수 확인함
눈으로 대강 봤을 때는 file이라는 내용을 복호화하고 복호화한 내용을 이용해서 어떤 두 값을 비교해서 다르면 알림창을 띄우고 같으면 정답이 나오는 것으로 유추함

_0x9a220 함수 시작 부분과 if문에 브레이크포인트를 걸고 비밀번호로 "1234"를 입력하여 "Confirm" 버튼 클릭함
if문 전까지 실행하면 다음과 같은 결과를 확인할 수 있음


함수 코드를 보면
_0x30bf04(사용자 입력값)를 _0x3eebe5에 넣고, 그 결과를 문자 배열로 바꿔서 각 문자의 charCodeAt()(= 바이트값)를 뽑음: _0x2ee89c: (16) [129, 220, 155, 219, 82, 208, ...] — 길이가 정확히 16.
임의 길이 문자열("1234")을 넣었는데 결과가 항상 16바이트로 고정된다는 건, _0x3eebe5가 입력을 고정 길이 다이제스트로 만드는 해시 함수라는 뜻
_0x2ee89c = Array.from(_0x3eebe5(_0x30bf04, null, raw=true)).map(c => c.charCodeAt())
그리고 같은 변수 _0x2ee89c를 인자로 두 번 넘기는 것을 확인함
_0x2fef58 = new _0x58829a['_0x14c3a3'][...](_0x2ee89c, _0x2ee89c);
디버거 Scope를 보면
description: 'Cipher Block Chaining', name: 'cbc' — 이건 CryptoJS 라이브러리가 내부적으로 CBC 모드 객체를 만들 때 붙이는 표준 메타데이터임
즉 이 객체는 CryptoJS의 AES-CBC 관련 생성자이고 인자 두 자리에 똑같은 값(_0x2ee89c)이 들어갔으니 key와 IV로 동일한 값이 쓰인다는 걸로 유추
_0x2fef58: _0x2ef5df {description: 'Cipher Block Chaining', name: 'cbc', ...}
_0x3eebe5가 어떤 해시 알고리즘인지 확인하기 위해 코드를 확인해보면
_0x3eebe5 함수 자체는 알고리즘이 아니라 어떤 모드로 수행할지 결정하는 분기점 역할만 수행하고 있음
function _0x3eebe5(_0x2c5c24, _0xceb417, _0x169ee3) {
if (!_0xceb417) {
if (!_0x169ee3)
return _0x42115c(_0x2c5c24);
return _0x4cd335(_0x2c5c24);
}
if (!_0x169ee3)
return _0x4fb15f(_0xceb417, _0x2c5c24);
return _0x2f5ed0(_0xceb417, _0x2c5c24);
}
다시 _0x9a220 함수를 확인해보면 항상 호출 시 key는 null, raw는 true임
- key가 falsy → 위쪽 블록
- raw가 true → !raw가 false → 안쪽 if를 건너뛰고 return _0x4cd335(data)
따라서 무조건 _0x4cd335 함수로만 들어감
_0x3eebe5(_0x30bf04, null, raw=true) // key 자리에 null
_0x3eebe5(odradurs1, null, raw=true) // key 자리에 null
_0x4cd335 함수를 추적해보면
Paul Johnston의 MD5 구현체 구조와 일치함을 확인 가능
( 참고: https://github.com/enyo/md5 )
function _0x4cd335(_0x301302) {
return _0x5d2b7f(_0x199598(_0x301302));
}
function _0x199598(_0x3e3b23) {
return unescape(encodeURIComponent(_0x3e3b23));
}
function _0x5d2b7f(_0x5b26ff) {
var _0x284707 = _0x2439;
return _0x81c0e8(_0x1bb977(_0x36c462(_0x5b26ff), _0x5b26ff[_0x284707('0x2c', 'i4ly')] * 0x8));
}
난독화된 코드의 최종 정리된 로직은 다음과 같음
6자리 비밀번호(입력값)를 MD5 해싱
→ AES-CBC(key=iv=그 해시)로 file 복호화
→ 복호화 결과를 다시 MD5 해싱해서 코드에 박혀있는 고정 해시값과 비교
function checkPassword(pass) {
const key = MD5(pass); // 16바이트, key이자 IV
const plaintext = AES_CBC_decrypt(file, key, key);
if (MD5(plaintext) != "고정 해시값")
return alert('Wrong');
document.write(`<img src="${plaintext}">`);
}
이 구조상 정답 비밀번호를 모르면 애초에 올바른 key/IV가 안 나와서 복호화 결과가 의미 없는 바이트가 되고
그 해시가 고정값과 절대 일치할 수 없음
→ 눈으로 값 하나 찾는 문제가 아니라 사실상 AES 복호화 + 해시 비교를 반복하는 브루트포스 문제로 결론
브루트포스로 사용자 입력값을 000101부터 991231까지 순회하는 코드 생성
- 8x/9x년생일 확률이 높다고 판단해서 연도(yy)를 99부터 역순으로 순회하도록 설정
- 정답 판별은 _0x9a220() 함수 자체의 반환값(내부 해시 비교 결과)을 그대로 사용함 (true면 정답, false면 오답)
- alert 창이 발생하지 않도록 설정
- 정답을 찾으면 document.write가 페이지 전체를 덮어써버리는 걸 막기 위해 document.write도 오버라이드해서 결과 문자열(img 태그의 src 값)만 콘솔/클립보드로 뽑아내도록 처리
window.alert = function(msg){ console.log('[alert]', msg); };
document.write = function(html){
const src = html.match(/src="([^"]+)"/)[1];
copy(src);
console.log('복사 완료, 길이:', src.length);
};
for (let yy = 99; yy >= 0; yy--) {
for (let mm = 1; mm <= 12; mm++) {
for (let dd = 1; dd <= 31; dd++) {
const cand = String(yy).padStart(2,'0') + String(mm).padStart(2,'0') + String(dd).padStart(2,'0');
if (_0x9a220(cand)) {
console.log('PASSWORD FOUND:', cand);
}
}
}
}
위 코드를 콘솔창에서 실행하여 패스워드 값을 획득

비밀번호를 입력하면 노출되는 이미지를 통해 플래그 확인 가능
