[🥉 Bronze 2] 3중으로 막아둔 안드로이드 버튼, 그래도 정적분석으로 뚫는다 — DreamHack [CodeEngn] MobileApp L02 풀이

2026-07-22·1분 읽기·

[🥉 Bronze 2] 3중으로 막아둔 안드로이드 버튼, 그래도 정적분석으로 뚫는다 — DreamHack [CodeEngn] MobileApp L02 풀이

L01과 같은 SEED-128 CBC + Java BigInteger 부호 인코딩 구조지만, 이번엔 유효 블록이 2개라 CBC 체이닝까지 풀어야 한다. onCreate()의 실행 조건은 참조비교(==)·하드코딩 상수· 불가능한 과거 시각 3중으로 막혀 있어, 앱을 실행해서는 절대 답이 안 나오게 설계됐다.

문제: 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"
APK 내부 — classes.dex와 네이티브 libKISACrypto.so, dex 타임스탬프가 앱 안의 날짜 상수와 거의 겹친다
APK 내부 — classes.dex와 네이티브 libKISACrypto.so, dex 타임스탬프가 앱 안의 날짜 상수와 거의 겹친다

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"
jadx 디컴파일 — Security 클래스의 key/iv 정적배열과 DecryptStr
jadx 디컴파일 — Security 클래스의 key/iv 정적배열과 DecryptStr

DecryptStr은 L01과 한 글자도 다르지 않다. hex 문자열을 BigInteger로 바이트로 바꾼 뒤 SEED로 복호한다. System.loadLibrary("KISACrypto")가 눈에 띄지만 L01에서 이미 확인했듯 표준 SEED-128이라 .so를 리버싱할 필요는 없다.

그럼 이 값을 어디서 호출하는지 MainActivity를 본다.

cat jadx_out/sources/com/namdaehyeon/findkey2/MainActivity.java
jadx 디컴파일 — onCreate의 실행 조건이 세 가지 이유로 절대 만족되지 않는다
jadx 디컴파일 — onCreate의 실행 조건이 세 가지 이유로 절대 만족되지 않는다

여기서 L01과 갈린다. L01은 버튼을 누르면 곧바로 DecryptStr 결과가 대화상자에 뜨는데, L02는 onCreate() 안에 조건문이 하나 더 있다.

if (makeDate() == "2013-11-02-12:35:03" && Volume() == 53) {
    this.aView.setText(keyString());
}

이 한 줄이 세 가지 이유로 절대 참이 될 수 없다.

  1. ==는 Java에서 String 내용 비교가 아니라 참조 비교다. makeDate()가 매번 SimpleDateFormat.format()으로 새로 만든 문자열 객체를 리턴하므로, 내용이 우연히 똑같아도 ==는 다른 객체라 거짓이다. .equals()를 썼어야 할 자리다.
  2. 설령 ==가 .equals()였더라도 makeDate()는 현재 시각을 담으므로 "2013-11-02-12:35:03"과 같아질 순간은 실행 중 단 1초, 그것도 기기 시각을 그렇게 맞춰야만 온다.
  3. Volume()은 하드코딩된 상수 33을 리턴한다. 조건은 Volume() == 53이니 이 비교는 코드가 도는 한 항상 거짓이다.

셋 중 하나만 걸려도 답은 안 뜨는데 세 개가 동시에 막혀 있다. 즉 이 앱은 실제로 실행해서는 절대 답을 볼 수 없게 설계됐다. keyString()이 넘기는 리터럴을 직접 복호하는 것 말고는 길이 없다.

public String keyString() {
    return Security.DecryptStr("-1c776f3a2fa678cae27879e87a74553c61009eefb037bbe2abdbd5e4314407e60000000000000000");
}

💣 핵심 — BigInteger 인코딩, 그리고 이번엔 블록이 두 개

L01에서 다룬 함정 두 가지(BigInteger 2의 보수 인코딩, key≠iv)는 여기서도 그대로 적용된다. Security 정적배열을 부호 없는 바이트로 다시 읽으면:

key = 33d24f8f082279f1e9f394370ad40589
iv  = 268d66a735a81a815fbad9fa36192513

keyString()의 리터럴은 -로 시작하는 음수 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 복호는 이렇게 체이닝된다.

2블록 CBC 체이닝 — block1은 IV와, block2는 block1의 암호문(C1)과 XOR한다
2블록 CBC 체이닝 — block1은 IV와, block2는 block1의 암호문(C1)과 XOR한다

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}")
solve.py 실행 — 두 블록을 한 번에 넘긴 openssl seed-cbc가 체이닝까지 처리해 문장 전체를 복원
solve.py 실행 — 두 블록을 한 번에 넘긴 openssl seed-cbc가 체이닝까지 처리해 문장 전체를 복원

복호된 문장은 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

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 2] 버튼 8.9억 번 대신 APK 속 SEED-128 CBC 를 직접 푼다 — DreamHack [CodeEngn] MobileApp L03 풀이
blog

[🥉 Bronze 2] 버튼 8.9억 번 대신 APK 속 SEED-128 CBC 를 직접 푼다 — DreamHack [CodeEngn] MobileApp L03 풀이

SmartApp L03.apk 는 버튼을 randomRange(44444) 번 눌러야 키값을 보여준다. 평균 8.9억 번이라 UI 로는 절대 못 본다. jadx 로 뜯어보면 그 값은 입력과 무관하게 Security.DecryptStr() 이 하드코딩 hex 를 SEED-128 CBC 로 복호한 결과였고, key 와 IV 는 dex 의 fill-array-data 페이로드에 상수로 박혀 있다. apktool 없이 classes.dex 에서 상수를 직접 긁어내 openssl seed-cbc 로 복호하면 키값 CodeEngn_4_Ever 가 나온다.
#dreamhack#ctf#reversing+9
2026-07-25#dreamhack +8
[🥉 Bronze 3] 안드로이드 APK 속 SEED-128 CBC 키값 복원 — DreamHack [CodeEngn] MobileApp L01 풀이
blog

[🥉 Bronze 3] 안드로이드 APK 속 SEED-128 CBC 키값 복원 — DreamHack [CodeEngn] MobileApp L01 풀이

SmartApp L01.apk 를 jadx 로 뜯어보니 버튼을 누르면 Security.DecryptStr() 가 하드코딩된 hex 문자열을 SEED-128 CBC 로 복호해 "Key" 로 띄운다. 암호문은 Java BigInteger 의 부호 있는 2의 보수로 인코딩돼 있고, key 와 IV 가 정적 배열에 그대로 박혀 있다. openssl 로 그대로 복호해 키값 H3ll0 C0de3ngn 를 얻는다.
#dreamhack#ctf#reversing+6
2026-07-16#dreamhack +8
[🥉 Bronze 2] 화면에서 지운 것은 지운 게 아니다 — DreamHack Gyul Order 풀이
blog

[🥉 Bronze 2] 화면에서 지운 것은 지운 게 아니다 — DreamHack Gyul Order 풀이

감귤 주문 관리 앱의 주문 목록에 세 건이 들어 있는데 화면에는 두 건만 뜬다. 세 번째 항목만 isVisible 이 false 라 렌더 루프에서 걸러질 뿐, 문자열은 classes3.dex 상수풀에 그대로 남아 있다. jadx 로 3분, smali 한 바이트를 뒤집어 실기기에 다시 올리면 화면에서도 보인다.
#dreamhack#ctf#reversing+7
2026-08-21#dreamhack +6