문제: DreamHack — [CodeEngn] MobileApp L02 분류: Reversing / Mobile (Android) 난이도: 🥉 Bronze 2 FLAG:
the key is november rain
같은 시리즈의 L01을 풀고 나서 이어서 받은 문제다. 구조는 거의 동일하다 — SEED-128 CBC로 하드코딩된 문자열을 복호해 그 값이 "키값"이 되는 형태. 다만 이번엔 두 가지가 달랐다. 유효한 SEED 블록이 하나가 아니라 둘이라 CBC 체이닝을 실제로 이어야 했고, 앱이 버튼을 눌러도 답을 보여주지 않도록 조건을 세 겹으로 막아뒀다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | [CodeEngn] MobileApp L02 |
| 난이도 | 🥉 Bronze 2 |
| 분류 | Reversing / Mobile |
| 제공 파일 | SmartApp L02.apk |
| 스택 | Android (Dalvik), 네이티브 libKISACrypto.so |
| 핵심 기법 | SEED-128 2블록 CBC 복호 + BigInteger 부호 인코딩 + 실행 자체가 막힌 UI |
🔬 코드 정찰
APK 안을 먼저 본다.
file "SmartApp L02.apk" && unzip -l "SmartApp L02.apk" | grep -E "dex|\.so"
classes.dex의 타임스탬프가 2013-11-02 12:42 — 뒤에서 볼 조건문에 박혀 있는 "2013-11-02-12:35:03"과 7분 차이다. 개발자가 그 상수를 앱을 빌드하던 그 시점 근처로 박아둔 흔적으로 보인다.
jadx로 디컴파일해서 Security 클래스부터 본다. L01과 완전히 같은 구조다.
jadx -q -r -d jadx_out "SmartApp L02.apk"
DecryptStr은 L01과 한 글자도 다르지 않다. hex 문자열을 BigInteger로 바이트로 바꾼 뒤 SEED로 복호한다. System.loadLibrary("KISACrypto")가 눈에 띄지만 L01에서 이미 확인했듯 표준 SEED-128이라 .so를 리버싱할 필요는 없다.
그럼 이 값을 어디서 호출하는지 MainActivity를 본다.
cat jadx_out/sources/com/namdaehyeon/findkey2/MainActivity.java
여기서 L01과 갈린다. L01은 버튼을 누르면 곧바로 DecryptStr 결과가 대화상자에 뜨는데, L02는 onCreate() 안에 조건문이 하나 더 있다.
if (makeDate() == "2013-11-02-12:35:03" && Volume() == 53) {
this.aView.setText(keyString());
}이 한 줄이 세 가지 이유로 절대 참이 될 수 없다.
==는 Java에서 String 내용 비교가 아니라 참조 비교다.makeDate()가 매번SimpleDateFormat.format()으로 새로 만든 문자열 객체를 리턴하므로, 내용이 우연히 똑같아도==는 다른 객체라 거짓이다..equals()를 썼어야 할 자리다.- 설령
==가.equals()였더라도makeDate()는 현재 시각을 담으므로"2013-11-02-12:35:03"과 같아질 순간은 실행 중 단 1초, 그것도 기기 시각을 그렇게 맞춰야만 온다. Volume()은 하드코딩된 상수 33을 리턴한다. 조건은Volume() == 53이니 이 비교는 코드가 도는 한 항상 거짓이다.
셋 중 하나만 걸려도 답은 안 뜨는데 세 개가 동시에 막혀 있다. 즉 이 앱은 실제로 실행해서는 절대 답을 볼 수 없게 설계됐다. keyString()이 넘기는 리터럴을 직접 복호하는 것 말고는 길이 없다.
public String keyString() {
return Security.DecryptStr("-1c776f3a2fa678cae27879e87a74553c61009eefb037bbe2abdbd5e4314407e60000000000000000");
}💣 핵심 — BigInteger 인코딩, 그리고 이번엔 블록이 두 개
L01에서 다룬 함정 두 가지(BigInteger 2의 보수 인코딩, key≠iv)는 여기서도 그대로 적용된다. Security 정적배열을 부호 없는 바이트로 다시 읽으면:
key = 33d24f8f082279f1e9f394370ad40589
iv = 268d66a735a81a815fbad9fa36192513keyString()의 리터럴은 -로 시작하는 음수 hex다. Java new BigInteger(hex, 16).toByteArray()는 이 값을 최소 길이의 부호 있는 2의 보수 big-endian으로 돌려주는데, 이번엔 그 결과가 40바이트로, L01(30바이트, 유효 블록 1개)보다 길다. 16바이트씩 블록으로 나누면 앞의 32바이트(2개 블록)가 유효 SEED 데이터고, 나머지 8바이트는 원본 리터럴 끝의 0000000000000000이 그대로 넘어온 zero-fill 잔재다 — 16바이트를 못 채워서 버려진다.
블록이 둘이라는 게 L01과의 실질적인 차이다. CBC 복호는 이렇게 체이닝된다.

P1 = D(C1) ⊕ IV는 L01과 같은 계산이지만, P2 = D(C2) ⊕ C1에서 C1(첫 블록 암호문)이 다시 XOR 키로 쓰인다. 이걸 놓치고 두 블록을 각각 IV로만 복호하면 두 번째 블록은 깨진 바이트가 나온다. 다행히 openssl seed-cbc는 여러 블록을 한 번에 넘기면 이 체이닝을 자동으로 처리해준다 — 직접 XOR을 짤 필요 없이 32바이트를 통째로 넘기면 된다.
🚀 Full Exploit
smali에서 key·iv·암호문 리터럴을 직접 파싱해 자체완결로 만들었다. L01의 solve.py와 뼈대는 같고, 블록 수를 리터럴 길이에서 계산하는 부분만 다르다.
import re
import subprocess
from pathlib import Path
BASE = Path(__file__).resolve().parent / "decoded" / "smali" / "com" / "namdaehyeon" / "findkey2"
SEC = BASE / "Security.smali"
MAIN = BASE / "MainActivity.smali"
def parse_smali_byte_array(text, label):
m = re.search(r":%s\s*\n\s*\.array-data 1\n(.*?)\.end array-data" % label, text, re.S)
vals = re.findall(r"(-?0x[0-9a-fA-F]+)t", m.group(1))
return bytes(int(v, 16) & 0xFF for v in vals)
def java_biginteger_hex_to_bytes(hexstr):
sign = -1 if hexstr.startswith('-') else 1
value = sign * int(hexstr.lstrip('-'), 16)
n = 1
while True:
try:
return value.to_bytes(n, 'big', signed=True)
except OverflowError:
n += 1
def pkcs7_strip(b):
pad = b[-1]
return b[:-pad] if 1 <= pad <= 16 and b[-pad:] == bytes([pad]) * pad else b
sec_text = SEC.read_text()
key = parse_smali_byte_array(sec_text, "array_0")
iv = parse_smali_byte_array(sec_text, "array_1")
print(f"[*] key = {key.hex()}")
print(f"[*] iv = {iv.hex()}")
ct_hex = re.search(r'const-string v1, "(-?[0-9a-f]+)"', MAIN.read_text()).group(1)
ct_bytes = java_biginteger_hex_to_bytes(ct_hex)
print(f"[*] cipherText (BigInteger.toByteArray) = {ct_bytes.hex()} ({len(ct_bytes)} bytes)")
n_blocks = len(ct_bytes) // 16
block = ct_bytes[:n_blocks * 16]
print(f"[*] {n_blocks} valid SEED block(s), trailing {len(ct_bytes) - len(block)} bytes are zero-fill artifact")
out = subprocess.run(
["openssl", "enc", "-d", "-seed-cbc", "-K", key.hex(), "-iv", iv.hex(),
"-nopad", "-provider", "legacy", "-provider", "default"],
input=block, capture_output=True)
pt = pkcs7_strip(out.stdout)
print(f"[+] decrypted = {pt.decode()!r}")
print(f"[+] flag submit = {pt.decode().lower()!r}")
복호된 문장은 The Key is November Rain. 이게 앱이 (실행이 불가능한 조건 뒤에 숨겨서) 보여주려던 값이다.
▶🐛 제출 삽질 — 복호된 문장 그대로는 오답
복호된 문자열을 그대로 제출했다가 오답 처리를 받았다. 문장에서 "키 자체"만 뽑아 November Rain으로도 시도했지만 이 역시 오답이었다. 소문자로 바꾼 전체 문장 the key is november rain을 넣으니 정답으로 처리됐다.
$ dreamhack.py wg 377 --flag "The Key is November Rain"
RESULT: 오답/실패
$ dreamhack.py wg 377 --flag "November Rain"
RESULT: 오답/실패
$ dreamhack.py wg 377 --flag "the key is november rain"
RESULT: 정답! 해결 처리됨복호 로직 자체(키·IV·블록 체이닝)는 처음부터 맞았다는 뜻이라, 막힌 지점은 암호가 아니라 제출 문자열의 형태였다. 정확히 대소문자 구분 때문인지 다른 정규화 규칙이 있는지는 플랫폼 내부 로직이라 단정할 수 없지만, 실제로 통과한 건 소문자 버전이었다.
📝 결론
클라이언트 로직이 "실행 못 하게" 막아놔도 정적분석 앞에서는 의미가 없다. == 참조비교, 하드코딩된 상수, 도달 불가능한 과거 시각 — 이 앱은 세 가지 서로 다른 방법으로 UI 경로를 봉인했다. 하지만 그 봉인은 전부 "런타임에 그 코드가 실행되는가"에 관한 것이라, 코드를 실행하지 않고 그냥 읽어서 같은 계산을 우리 손으로 재현하면 아무 의미가 없어진다. 앱을 실제로 설치해서 눌러볼 필요 자체가 없었다.
CBC는 블록이 늘어나면 체이닝도 같이 늘어난다. L01처럼 유효 블록이 하나뿐이면 D(C) ⊕ IV 한 번으로 끝나지만, 블록이 여러 개면 두 번째 블록부터는 이전 블록의 암호문이 XOR 키가 된다. 이 사실을 놓치고 모든 블록을 IV로만 복호하면 첫 블록만 맞고 나머지는 전부 깨진다.
제출 형식은 복호 결과와 다를 수 있다. 암호를 정확히 풀었다고 끝이 아니다. 대소문자·공백·문장 전체냐 일부냐처럼, 복호된 "값"과 플랫폼이 기대하는 "제출 문자열"이 정확히 같은 형태가 아닐 수 있다는 걸 이번 문제에서 확인했다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…