HTB: Waiting Challenge

Waiting - HackTheBox Challenge Writeup

Challenge Information

FieldValue
NameWaiting
CategoryMisc (Reverse Engineering / Mobile)
DifficultyMedium
Authord3vn0mi

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.so native 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.

Terminal window
# 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>.zip

Step 2: Unpack and fingerprint the APK

Terminal window
mkdir -p work/ex && cd work/ex
unzip -o -q ../app-release.apk
# Look for native libs / loadLibrary calls / suspicious method names
ls lib/x86_64/
strings lib/x86_64/libsecrets.so | grep -i secret

This 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

Terminal window
pip3 install --break-system-packages -q androguard
python3 - <<'PY'
from androguard.misc import AnalyzeAPK
a, 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())
PY

This 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

Terminal window
objdump -T lib/x86_64/libsecrets.so | grep -i getdx
objdump -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"]))
PY

Step 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

ToolPurpose
unzipExtract the password-protected challenge archive and the APK contents
androguardParse the DEX, confirm the Java→JNI call flow into the native secret getter
objdumpDisassemble libsecrets.so and locate the target JNI export
pyelftoolsParse ELF PT_LOAD segments for accurate emulator memory mapping
Unicorn EngineEmulate just the pointer-building prologue of the native function, sidestepping manual SIMD decoding
strings / fileInitial 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-plugin fingerprint. A libsecrets.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 (punpcklqdq shuffles) 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.apk was 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}