HTB: Waiting Challenge
Waiting - HackTheBox Challenge Writeup
Challenge Information
| Field | Value |
|---|---|
| Name | Waiting |
| Category | Misc (Reverse Engineering / Mobile) |
| Difficulty | Medium |
| Author | d3vn0mi |
Description
The challenge ships an Android app that claims to store a secret “securely,” even if the application has been tampered with. The goal is to prove that claim wrong and retrieve the secret without ever running the app on a device or emulator.
Solution Overview
The handed-out app-release.apk turned out to be a 0-byte stub — the real APK was bundled separately as a password-protected zip (/out/<id>.zip, password hackthebox).
Once unpacked, the app fingerprinted as using the klaxit hidden-secrets-gradle-plugin — a well-known Android library for hiding secrets behind a native (NDK) layer instead of storing them as plaintext strings or resources. Telltale signs:
- A bundled
libsecrets.sonative library System.loadLibrary("secrets")in the app’s Java/Kotlin code- An obfuscated native method,
com.example.waiting.Secrets.getdxXEPMNe(), used to retrieve the actual secret string
The app’s SecretActivity also runs a battery of anti-tamper checks (an obfuscated utils/a.a() routine, referrer checks, package-name/signature checks) before it will display the secret on screen. Those checks are a red herring for a static-extraction approach — they only gate the UI reveal, not the underlying data. Since the secret has to exist in some computable form inside the native library regardless of runtime checks, the correct path was to reverse libsecrets.so directly rather than trying to run/patch the app.
Inside the .so, the JNI export responsible for the secret builds two 48-entry pointer arrays on the stack — one pointing at “obfuscated” bytes, one at “key” bytes, both ultimately referencing offsets into the library’s .rodata section. A tight loop then does:
secret[i] = *A[i] XOR *B[i] // for i in 0..0x30 (48 characters)…and returns the result to Java via NewStringUTF. The compiler had auto-vectorized the array-building prologue with SIMD instructions (punpcklqdq), which makes manually reading the pointer tables out of the disassembly extremely error-prone. Instead of hand-decoding the SIMD shuffles, the native prologue was emulated just far enough to have the two pointer arrays materialize correctly in memory, then read them straight out of emulated memory.
Key Steps
Step 1: Recover the real APK
The provided app-release.apk was a placeholder; the actual artifact was a separate password-protected archive.
# The distributed app-release.apk was 0 bytes — a decoy/stub.# The real APK lived in a companion zip, password-protected:unzip -o -P hackthebox /out/<challenge-id>.zipStep 2: Unpack and fingerprint the APK
mkdir -p work/ex && cd work/exunzip -o -q ../app-release.apk
# Look for native libs / loadLibrary calls / suspicious method namesls lib/x86_64/strings lib/x86_64/libsecrets.so | grep -i secretThis surfaced libsecrets.so, System.loadLibrary("secrets"), and the mangled native method name getdxXEPMNe() — the classic signature of the klaxit hidden-secrets-gradle-plugin.
Step 3: Confirm the DEX call flow with androguard
pip3 install --break-system-packages -q androguard
python3 - <<'PY'from androguard.misc import AnalyzeAPKa, d, dx = AnalyzeAPK("../app-release.apk")
# Confirm SecretActivity calls the obfuscated native getter,# and that anti-tamper checks (utils/a.a) only gate display, not computation.for m in dx.find_methods(".*Secrets.*", "getdxXEPMNe.*"): print(m.get_method())PYThis confirmed the secret is produced entirely inside the native call — the Java side just displays whatever the JNI function returns.
Step 4: Locate and map the native routine
objdump -T lib/x86_64/libsecrets.so | grep -i getdxobjdump -d lib/x86_64/libsecrets.so --disassemble=Java_com_example_waiting_Secrets_getdxXEPMNe
python3 - <<'PY'from elftools.elf.elffile import ELFFile
# Enumerate PT_LOAD segments so they can be faithfully mapped# into an emulator with correct file->virtual address offsets.with open("lib/x86_64/libsecrets.so", "rb") as f: elf = ELFFile(f) for seg in elf.iter_segments(): if seg["p_type"] == "PT_LOAD": print(hex(seg["p_vaddr"]), hex(seg["p_filesz"]), hex(seg["p_memsz"]))PYStep 5: Emulate the pointer-building prologue with Unicorn
Rather than manually decode the SIMD (punpcklqdq) shuffles that build the two 48-entry pointer arrays, the prologue was emulated just far enough for the arrays to be populated in memory, then stopped before the std::string constructor calls (which aren’t needed).
from unicorn import *from unicorn.x86_const import *
# 1. Map every PT_LOAD segment of libsecrets.so at its real file offset.# 2. Set up a stack, point RIP at the start of the JNI export.# 3. Run only the address range that builds the pointer arrays# (0xeb90 -> 0xf170), then stop -- we don't need the XOR loop# or the string construction, just the two pointer tables.mu = Uc(UC_ARCH_X86, UC_MODE_64)# ... map segments, init regs, mu.emu_start(0xeb90, 0xf170) ...
# KEY GOTCHA: the arrays are stored relative to the *final* RSP,# i.e. AFTER the `sub $0x378, %rsp` prologue instruction executes --# not the RSP value at function entry. Reading at the wrong RSP# silently returns garbage pointers.rsp = mu.reg_read(UC_X86_REG_RSP)obf_ptrs = [mu.mem_read(rsp + 0x1f0 + i*8, 8) for i in range(48)]key_ptrs = [mu.mem_read(rsp + 0x70 + i*8, 8) for i in range(48)]Step 6: XOR the two byte streams to recover the flag
# Each pointer in the two 48-entry tables addresses a single byte# inside the mapped .rodata image. secret[i] = *A[i] XOR *B[i].secret_bytes = bytearray()for a_ptr, b_ptr in zip(obf_ptrs, key_ptrs): a_byte = mu.mem_read(int.from_bytes(a_ptr, "little"), 1)[0] b_byte = mu.mem_read(int.from_bytes(b_ptr, "little"), 1)[0] secret_bytes.append(a_byte ^ b_byte)
print(secret_bytes.decode()) # -> HTB{REDACTED}Tools Used
| Tool | Purpose |
|---|---|
unzip | Extract the password-protected challenge archive and the APK contents |
androguard | Parse the DEX, confirm the Java→JNI call flow into the native secret getter |
objdump | Disassemble libsecrets.so and locate the target JNI export |
pyelftools | Parse ELF PT_LOAD segments for accurate emulator memory mapping |
Unicorn Engine | Emulate just the pointer-building prologue of the native function, sidestepping manual SIMD decoding |
strings / file | Initial static triage of the extracted APK and native library |
Key Learnings
- “Tamper-resistant” is a UI-layer claim, not a data-layer one.
SecretActivity’s anti-tamper checks only gated whether the secret was displayed; they did nothing to protect the underlying computation. Static reverse engineering of the native library bypassed the runtime gate entirely. - klaxit
hidden-secrets-gradle-pluginfingerprint. Alibsecrets.so+System.loadLibrary("secrets")+ a mangled native getter method name (getdxXEPMNe) is a reliable signature for this plugin — worth recognizing immediately in future mobile challenges. - Emulate instead of hand-decode SIMD. Compiler-vectorized array-construction code (
punpcklqdqshuffles) is painful to reverse by hand but trivial to let an emulator (Unicorn) execute for you — run only the address range you actually need, then read the resulting registers/memory directly. - Stack offsets must be read relative to the effective RSP, i.e. after the function’s stack-allocation prologue (
sub $N, %rsp) has executed — not the entry-point RSP. Getting this wrong silently yields garbage pointers instead of an error. - Decoy artifacts are part of the challenge. The distributed
app-release.apkwas a deliberate 0-byte stub; the real target was in a separate password-protected archive. Always verify handed-out binaries are non-trivial before spending time analyzing them.
Flag
HTB{REDACTED}