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

2026-07-16·1분 읽기·

[🥉 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 — [CodeEngn] MobileApp L01 분류: Reversing / Mobile (Android) 난이도: 🥉 Bronze 3 FLAG: H3ll0 C0de3ngn

APK 하나가 주어지고 설명은 딱 한 줄, "키값을 찾으시오". 앱을 켜서 버튼을 누르면 대화상자에 Key 라는 제목과 함께 어떤 문자열이 뜨는데, 그 문자열이 정답이다. 굳이 에뮬레이터를 띄우지 않아도 코드만 정적으로 읽으면 그 값을 그대로 계산해낼 수 있다.


문제 개요

항목내용
문제명[CodeEngn] MobileApp L01
난이도🥉 Bronze 3
분류Reversing / Mobile
제공 파일SmartApp L01.apk
스택Android (Dalvik), 네이티브 libKISACrypto.so
핵심 기법SEED-128 CBC 복호 + Java BigInteger 부호 인코딩 해독

풀이 흐름은 짧다. APK 를 디컴파일해서 버튼 콜백이 무엇을 하는지 본다. Security.DecryptStr() 안에서 하드코딩된 hex 문자열을 SEED 로 복호한다는 걸 확인하고, 같은 클래스의 정적 배열에서 key 와 IV 를 꺼낸 다음, 그 암호문을 그대로 복호하면 끝이다. 걸림돌은 딱 두 개 — 암호문이 BigInteger 로 인코딩돼 있다는 점과, key 와 IV 를 헷갈리기 쉽다는 점이다.



🧩 배경 — KISA SEED, 그리고 이 앱의 정체

SEED 는 한국인터넷진흥원(KISA)이 만든 128비트 블록 암호다. AES 와 같은 16바이트 블록에 128비트 키를 쓰고, 이 문제에서는 CBC(Cipher Block Chaining) 운용 모드로 동작한다. 앱은 KISA 가 배포하는 레퍼런스 구현을 그대로 가져다 썼다 — Java 래퍼 클래스 org.kisa.SEED 가 init / process / close 3단계 API 를 제공하고, 실제 블록 연산은 네이티브 라이브러리에 들어 있다.

// org/kisa/SEED.java — 실연산은 전부 native (libKISACrypto.so)
private native int internalSeedCBCProcessDec(int i, int[] iArr, ...);
private native int internalSeedProcessBlocks(int[] iArr, ...);
public int init(int enc, byte[] key, byte[] iv) { ... }   // enc: 1=암호화, 0=복호
public int process(byte[] in, int inLen, byte[] out, int off) { ... }

여기서 중요한 건, 네이티브에 숨겨진 게 무슨 커스텀 로직이 아니라 표준 SEED-128 이라는 점이다. 표준이면 굳이 .so 를 리버싱할 필요 없이 openssl 의 seed-cbc 로 그대로 복호할 수 있다. System.loadLibrary("KISACrypto") 는 눈길을 끌지만 결국 미끼에 가깝다.


🔬 코드 정찰

먼저 APK 안에 뭐가 들었는지 본다. .apk 는 사실 zip 이라 그냥 목록만 봐도 구조가 보인다.

file "SmartApp L01.apk"
unzip -l "SmartApp L01.apk" | grep -E "dex|\.so|MANIFEST"
file 과 unzip -l 출력 — SmartApp L01.apk 는 사실 zip 이고, 안에는 앱 바이트코드인 class.dex 469348바이트와 네이티브 SEED 구현 lib/armeabi/libKISACrypto.so 34548바이트가 들어 있다
file 과 unzip -l 출력 — SmartApp L01.apk 는 사실 zip 이고, 안에는 앱 바이트코드인 class.dex 469348바이트와 네이티브 SEED 구현 lib/armeabi/libKISACrypto.so 34548바이트가 들어 있다

class.dex(앱 바이트코드)와 lib/armeabi/libKISACrypto.so(SEED 네이티브 구현) 두 개가 핵심이다. dex 를 사람이 읽을 수 있는 Java 로 되돌리기 위해 jadx 로 디컴파일한다.

jadx -q -r -d out "SmartApp L01.apk"
sed -n '6,45p' out/sources/com/namdaehyeon/findkey1/Security.java
jadx 로 되돌린 Security.java — static 블록에서 KISACrypto 를 로드하며 16바이트 key 와 iv 배열을 상수로 박아두고, EncryptStr 과 DecryptStr 이 그 값으로 SEED 를 초기화한다
jadx 로 되돌린 Security.java — static 블록에서 KISACrypto 를 로드하며 16바이트 key 와 iv 배열을 상수로 박아두고, EncryptStr 과 DecryptStr 이 그 값으로 SEED 를 초기화한다

Security 클래스 전체가 이 문제의 답지다. 정적 초기화 블록에 key 와 iv 가 바이트 배열로 박혀 있고, DecryptStr() 가 복호 로직이다. 코드를 그대로 옮기면 이렇다.

public class Security {
    static {
        System.loadLibrary("KISACrypto");
        key = new byte[]{51, -46, 79, -113, 8, 34, 121, -15, -23, -13, -108, 55, 10, -44, 5, -119};
        iv  = new byte[]{38, -115, 102, -89, 53, -88, 26, -127, 95, -70, -39, -6, 54, 25, 37, 19};
    }
    public static String DecryptStr(String decText) {
        if (decText == null || decText.equals("")) return "";
        byte[] cipherText = new BigInteger(decText.trim(), 16).toByteArray();  // ← hex → 바이트
        byte[] plainText  = new byte[144];
        SEED seed = new SEED();
        seed.init(0, key, iv);                                                 // ← 0 = 복호 모드
        int outputTextLen = seed.process(cipherText, cipherText.length, plainText, 0);
        seed.close(plainText, outputTextLen);
        return new String(plainText).trim();
    }
}

그럼 이 DecryptStr() 에 실제로 어떤 문자열이 들어가는지가 남았다. 버튼 콜백을 따라가면 MainActivity 의 onClick 이 인자를 직접 넘긴다.

// MainActivity$1.onClick — 버튼을 누르면
alert.setTitle("Key");
alert.setMessage(Security.DecryptStr(
    "-1aaa755a1e60915baff1d4cb64cb221a0000000000000000000000000000"));
alert.show();

정리하면 목표는 명확하다. 위 hex 문자열을 BigInteger 로 바이트로 바꾸고, key/iv 로 SEED-128 CBC 복호를 돌리면 대화상자에 뜨는 그 "키값"이 나온다.


🐛 삽질 — IV 를 key 로 착각해서 계속 깨졌다

처음엔 key 와 IV 를 대충 눈으로 긁어서 openssl 에 넣었는데 결과가 계속 깨진 바이트로 나왔다. CBC 복호는 첫 블록 평문이 D(C1) ⊕ IV 라, IV 가 한 바이트라도 틀리면 그 블록 전체가 쓰레기가 된다. 이 문제는 유효 블록이 딱 하나뿐이라 IV 를 틀리는 순간 답 전체가 날아간다.

▶🐛 삽질 — 잘못된 IV로 돌린 첫 복호 (garbage)

배열이 두 개(array_0, array_1) 있는데, 초기 추출에서 IV 자리에 실수로 key 값을 그대로 복사해 두 값이 같다고 착각했다. 그 상태로 복호하면 이렇게 의미 없는 바이트가 나온다.

# 잘못된 IV(=key)로 복호 → garbage
$ printf 'e5558aa5e19f6ea4500e2b349b34dde6' | xxd -r -p | \
    openssl enc -d -seed-cbc -nopad -provider legacy \
    -K 33d24f8f082279f1e9f394370ad40589 \
    -iv 33d24f8f082279f1e9f394370ad40589 | xxd
00000000: 9b1e 7d2c ...  (읽을 수 없는 바이트)

해결은 단순했다. jadx 가 보여준 두 배열을 각각 부호 없는 바이트로 다시 디코딩해서 정말 다른 값인지 확인했다.

sbyte = lambda arr: bytes(x & 0xFF for x in arr).hex()
key = [51, -46, 79, -113, 8, 34, 121, -15, -23, -13, -108, 55, 10, -44, 5, -119]
iv  = [38, -115, 102, -89, 53, -88, 26, -127, 95, -70, -39, -6, 54, 25, 37, 19]
print("key =", sbyte(key))
print("iv  =", sbyte(iv))
print("key == iv ?", sbyte(key) == sbyte(iv))
두 배열 디코딩 — key 와 iv 는 서로 다른 값
두 배열 디코딩 — key 와 iv 는 서로 다른 값

key == iv ? False. IV 는 268d66a735a81a815fbad9fa36192513 으로 key 와 완전히 다른 값이었다. 이걸 바로잡으니 복호가 한 번에 풀렸다.


💣 핵심 — BigInteger 부호 인코딩과 진짜 IV

두 지점을 정확히 짚으면 나머지는 기계적이다.

1) 암호문이 BigInteger 의 2의 보수로 인코딩돼 있다. DecryptStr 에 넘어온 문자열은 맨 앞에 - 가 붙은 음수 hex 다. Java new BigInteger("-1aaa…0000", 16) 은 이걸 음수로 파싱하고, .toByteArray() 는 최소 길이의 부호 있는 2의 보수 big-endian 을 돌려준다.

원래 문자열의 크기 부분은 1aaa755a1e60915baff1d4cb64cb221a 뒤에 0 이 14바이트 붙은 30바이트 값이다. 음수라서 2의 보수를 취하면 상위 16바이트가 뒤집혀 e5558aa5e19f6ea4500e2b349b34dde6 가 되고, 하위 14바이트는 그대로 0 이 된다. 즉 실제 SEED 로 들어가는 암호문은 e5558aa5…dde6 한 블록(16B)이 전부고, 뒤따르는 0 바이트들은 블록을 못 채워 버려진다.

2) CBC 복호의 첫 블록은 IV 에 전적으로 의존한다.

CBC 복호 — 각 블록을 복호한 뒤 앞 블록(첫 블록은 IV)과 XOR 한다
CBC 복호 — 각 블록을 복호한 뒤 앞 블록(첫 블록은 IV)과 XOR 한다

출처: Wikimedia Commons, WhiteTimberwolf, public domain

그림처럼 첫 평문 블록은 블록복호(C1) ⊕ IV 다. 유효 블록이 하나뿐인 이 문제에서 IV 를 틀리면 답 전체가 어긋난다. 앞의 삽질이 정확히 이 함정이었다.

전체를 한 장으로 정리하면 이렇다.

DecryptStr 복원 흐름 — hex 문자열에서 키값까지
DecryptStr 복원 흐름 — hex 문자열에서 키값까지


🎯 복호 실행

표준 SEED 이므로 .so 를 건드릴 것도 없이 openssl 의 seed-cbc 로 첫 블록만 복호한다. 패딩은 아직 붙어 있으니 -nopad 로 그대로 받아 눈으로 확인한다. (SEED 는 레거시 알고리즘이라 -provider legacy 를 함께 지정해야 한다.)

printf 'e5558aa5e19f6ea4500e2b349b34dde6' | xxd -r -p | \
  openssl enc -d -seed-cbc -nopad -provider legacy -provider default \
    -K 33d24f8f082279f1e9f394370ad40589 \
    -iv 268d66a735a81a815fbad9fa36192513 | xxd
openssl SEED-CBC 복호 — H3ll0 C0de3ngn + PKCS#7 패딩
openssl SEED-CBC 복호 — H3ll0 C0de3ngn + PKCS#7 패딩

48 33 6c 6c 30 20 43 30 64 65 33 6e 67 6e 이 ASCII 로 H3ll0 C0de3ngn 이고, 뒤에 02 02 가 붙어 있다. 14바이트 평문을 16바이트 블록에 맞추느라 PKCS#7 이 2바이트를 채운 것이다. 원본 코드가 new String(plainText).trim() 으로 뒤를 잘라내는 이유가 여기 있다.


🚀 Full Exploit

앞의 두 함정(BigInteger 인코딩, 진짜 IV)만 코드로 옮기면 APK 하나로 끝나는 자체 완결 스크립트가 된다. smali 에서 key/iv 와 암호문 문자열을 직접 파싱하므로 값을 손으로 옮겨 적을 필요도 없다.

# solve.py — smali 에서 key/iv/암호문을 파싱해 SEED-CBC 로 복호
import re, subprocess
from pathlib import Path
 
BASE = Path(__file__).resolve().parent / "extracted" / "smali376" / "smali_class"
SEC  = BASE / "com" / "namdaehyeon" / "findkey1" / "Security.smali"
MAIN = BASE / "com" / "namdaehyeon" / "findkey1" / "MainActivity$1.smali"
 
def parse_smali_byte_array(text, label):
    m = re.search(r":%s\s*\.array-data 1(.*?)\.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_to_bytes(hex_str):
    v = int(hex_str, 16)                                   # '-1aaa…' → 음수
    if v == 0:
        return b"\x00"
    n = (v if v > 0 else -v - 1).bit_length() // 8 + 1     # 부호 비트 자리 확보
    return v.to_bytes(n, "big", signed=True)
 
def seed_cbc_decrypt(ct, key, iv):
    out = subprocess.run(
        ["openssl", "enc", "-d", "-seed-cbc",
         "-K", key.hex(), "-iv", iv.hex(), "-nopad",
         "-provider", "legacy", "-provider", "default"],
        input=ct[:16], capture_output=True)               # 유효 블록은 선두 16B
    return out.stdout
 
def pkcs7_strip(b):
    pad = b[-1]
    return b[:-pad] if 1 <= pad <= 16 and b[-pad:] == bytes([pad]) * pad else b
 
sec = SEC.read_text()
key = parse_smali_byte_array(sec, "array_0")
iv  = parse_smali_byte_array(sec, "array_1")
hex_str = re.search(r'const-string v1, "(-?[0-9a-fA-F]+)"', MAIN.read_text()).group(1)
 
ct = java_biginteger_to_bytes(hex_str)
pt = seed_cbc_decrypt(ct, key, iv)
print(f"[*] key        = {key.hex()}")
print(f"[*] iv         = {iv.hex()}")
print(f"[*] ciphertext = {ct.hex()}")
print(f"[+] KEY value  = {pkcs7_strip(pt).decode(errors='replace')!r}")
solve.py 실행 — smali 파싱부터 키값까지 자동
solve.py 실행 — smali 파싱부터 키값까지 자동

앱을 켜서 버튼을 누르면 뜨는 그 대화상자 값이 이것과 같다. 제출할 키값은 H3ll0 C0de3ngn.



📝 결론

표준 알고리즘이면 네이티브를 리버싱할 필요가 없다.

libKISACrypto.so 라는 네이티브 라이브러리가 눈에 걸리지만, 그 안이 표준 SEED-128 인 이상 .so 를 뜯을 이유가 없다. key·IV·암호문만 확보하면 openssl 로 그대로 복호된다. 커스텀 연산이 아니라 표준 구현을 그대로 링크한 앱에서 자주 통하는 지름길이다.

IV 는 key 가 아니다.

CBC 의 첫 블록 평문은 IV 에 전적으로 의존하는데, 이 문제는 유효 블록이 하나뿐이라 IV 한 바이트만 틀려도 답 전체가 깨진다. 배열이 둘 있으면 어느 쪽이 key 고 어느 쪽이 IV 인지 디코딩해서 확실히 구분하는 게 먼저다.

BigInteger 로 인코딩된 암호문은 부호를 조심한다.

Java 의 BigInteger(str, 16).toByteArray() 는 2의 보수라, 최상위 비트가 1 이면 값이 음수로 잡히고 앞뒤가 뒤집힌다. 이 문제의 -1aaa… 처럼 부호가 붙은 hex 는 단순 bytes.fromhex 로는 복원되지 않는다. 인코딩 규칙을 그대로 흉내 내야 원래 바이트가 나온다.

방어 관점에서 보면, 이런 앱의 근본 문제는 키를 앱 안에 그대로 박아둔 것 이다. 대칭키와 IV 가 dex 정적 배열에 상수로 들어 있으면 복호는 시간문제다. 클라이언트에 비밀을 두면 그건 더 이상 비밀이 아니다. 진짜 보호가 필요하면 키는 서버에 두고, 단말에는 하드웨어 기반 키스토어(Android Keystore)처럼 추출이 어려운 저장소를 쓰는 쪽이 맞다.

이 글이 도움이 됐나요?

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 2] 3중으로 막아둔 안드로이드 버튼, 그래도 정적분석으로 뚫는다 — DreamHack [CodeEngn] MobileApp L02 풀이
blog

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

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