HTB: Angler Challenge
Angler - HackTheBox Challenge Writeup
Challenge Information
| Field | Value |
|---|---|
| Name | Angler |
| Category | Mobile / Reversing (Misc) |
| Difficulty | Medium |
| Author | d3vn0mi |
Description
The skilled fisherman used his full strength and expertise to hook the fish. Can you beat him and set the fish free?
The challenge ships as Angler.apk, an Android application whose flag is computed entirely inside a native library rather than the Java/Dalvik layer — a classic move to push reversers off the beaten JADX path and into x86_64/ARM disassembly.
Solution
The APK’s MainActivity only declares a JNI bridge — native String getInfo(String) — backed by libangler.so. All of the interesting logic (string decoding, comparison, flag assembly) lives in native code, split across a red-herring function and the real one, glued together by a multi-stage string transformation pipeline operating on hex-encoded blobs in .rodata.
Rather than hand-translate every one of those stages back to Python, the fastest path was to make the stripped Android .so run natively under glibc via Python ctypes — patch its dynamic linking metadata until dlopen() accepted it, then just call the real function and read the result back.
Key Steps
1. Triage the APK with Androguard
# Load the APK and dump manifest / entry pointsfrom androguard.core.apk import APKa = APK('Angler.apk')print('package:', a.get_package())# MainActivity declares: native String getInfo(String)A quick look at the Java layer showed a decoy XOR-encoded string (XOR 0x10) that decodes to a taunt:
y = "@|uqcu0t\x7f~7d0{y||0}u1\x1aY7||0du||0ie0gxubu0dxu0v|qw0yc"print(''.join(chr(ord(c) ^ 0x10) for c in y))# -> "Please don't kill me! I'll tell you where the flag is."Confirming the real work happens in getInfo, calling into libangler.so.
2. Extract and inspect the native library
unzip -o -q Angler.apk 'lib/*' -d extfile ext/lib/x86_64/libangler.soInteresting strings turned up directly in .rodata:
strings -a -tx ext/lib/x86_64/libangler.so | grep -iE "HTB|flag|found|fish|angler"# ...# b42d3 HTB{REDACTED} <- looks like flag material, but XOR/transform-encoded# b43ba You found the flag# b43a0 I am not here, I am there3. Disassemble getInfo and trace the call graph
objdump -d --start-address=0x49e00 --stop-address=0x4a400 \ ext/lib/x86_64/libangler.soJava_com_..._getInfo calls two mangled C++ symbols:
call _Z8illusionPKc ; decoy — loops over a fake "HTB{REDACTED}" 100 times, does nothing usefulcall _Z2nePKc ; the REAL comparison routineDisassembling _Z2ne (ne(const char*)) revealed the actual flag logic:
call _Z2a1v ; builds string A = a1()...xorb $0x10,(%rdx) ; XOR every byte with 0x10...add $0xef,%bl ; then add 0xEF (mod 256)...call strcmp@plt ; compare transformed A against the user-supplied inputSo ne() computes transform(a1()) and strcmps it against the argument — the transformed output of a1() is the flag.
4. Trace the a1() construction chain
Following the call graph from a1() down:
a1() = a4( a3( a2( w4( q1(string4) ) ) ) )q1(hex_string)— hex-decodes an embedded blob into"160x168x162x..."w4(...)— parses thex-separated numbers, computeschar = (value + 10) / 2per tokena2(...)— decodes another hex blob (string3) and computes(byte + 91) % 128, then combines with thew4outputa3(...)— callsde3(), a byte-transform routine driven by a jump table keyed on character class (lower/upper/digit)a4(...)— callsre3(), another jump-table-based transform, and concatenates the final pieces
# Map every constant to its raw string contentb42d3 "HTB{REDACTED}" (decoy — illusion())b432f "1360x1368x..." string4 (feeds q1 -> w4)b4312 "7b0c797a7d..." string3 (feeds a2)b42f5 "5767575..." string5 (feeds a3)Fully reversing de3’s jump table and re3 by hand was possible but slow and error-prone — five nested layers of std::string manipulation plus a computed-goto dispatch table. Since libc++ is statically linked into the .so, only a handful of Bionic-libc symbols were actually missing to make the library runnable on a normal Linux glibc host.
5. Make the stripped Android .so execute under glibc
# Diff the .so's undefined dynamic symbols against glibc's exportsnm -D --undefined-only ext/lib/x86_64/libangler.so | sed 's/@LIBC//' > need.txtcomm -23 <(sort need.txt) <(sort have.txt)# => only 4 symbols missing: __errno, __sF, __strlen_chk, android_set_abort_messageBuilt a tiny shim providing exactly those four with as/ld:
.text.globl __errno__errno: leaq myerrno(%rip), %rax ret.globl __strlen_chk....datamyerrno: .long 0__sF: .zero 0x1000as shim.s -o shim.old -shared -o libshim.so shim.oRepointed the library’s NEEDED entries from Bionic to glibc equivalents:
patchelf --replace-needed liblog.so libshim.so libangler_patched.sopatchelf --replace-needed libc.so libc.so.6 libangler_patched.sopatchelf --replace-needed libm.so libm.so.6 libangler_patched.sopatchelf --replace-needed libdl.so libpthread.so.0 libangler_patched.sopatchelf --set-rpath /tmp/work libangler_patched.soFirst attempt still failed — glibc rejected the Bionic LIBC symbol version:
OSError: /lib/x86_64-linux-gnu/libc.so.6: version `LIBC' not foundBlindly zeroing DT_VERSYM/DT_VERNEED caused a segfault inside ld.so’s versioned symbol lookup (confirmed with gdb). The fix was more surgical: keep DT_VERSYM but rewrite every .gnu.version table entry to 1 (unversioned), and only neutralize DT_VERNEED/DT_VERNEEDNUM:
import struct# Set every .gnu.version (versym) entry to 1 -> "unversioned symbol"versym_off, versym_size = 0x2d0f0, 4226for i in range(versym_size // 2): struct.pack_into('<H', data, versym_off + i*2, 1)
# Neutralize DT_VERNEED / DT_VERNEEDNUM in the dynamic section (leave DT_VERSYM intact)for tag in (0x6ffffffe, 0x6fffffff): # DT_VERNEED, DT_VERNEEDNUM zero_dynamic_tag(data, tag)With that, dlopen() finally succeeded:
import ctypeslib = ctypes.CDLL('/tmp/work/lib4.so', mode=ctypes.RTLD_GLOBAL)# LOADED OK6. Call the real routine and apply the final transform
lib._Z2a1v.restype = ctypes.c_char_praw = lib._Z2a1v() # -> b'UYVUUSXcXZWgXVVgTUXSTTVgWXWgWgWUVgTUXUVgWYTQTQWcTRWfTZXe'
# Apply the exact transform ne() uses before strcmp: XOR 0x10, then +0xEF (mod 256)transformed = bytes(((b ^ 0x10) + 0xEF) & 0xFF for b in raw)print(transformed)# -> b'4854427b796f755f3472335f676f6f645f34745f6830306b316e397d'That output is an ASCII hex string. Decoding it gives the flag directly:
print(bytes.fromhex('4854427b796f755f3472335f676f6f645f34745f6830306b316e397d').decode())# -> HTB{REDACTED}Flag
HTB{REDACTED}Tools Used
- Androguard — APK/DEX triage, manifest and string extraction
- objdump — x86_64 disassembly of the native
.so - nm / readelf — symbol table and ELF metadata inspection
- patchelf — rewriting
NEEDEDentries and RPATH - as / ld (GNU binutils) — building a minimal glibc shim for missing Bionic symbols
- gdb — diagnosing the
ld.sosegfault caused by naive version-tag stripping - Python
ctypes+ struct — raw ELF patching (.gnu.version, dynamic tags) and finally invoking the native function directly
Key Learnings
- Android native libraries built against Bionic libc++ statically link libc++ itself — only the small set of true Bionic-specific libc calls (
__errno,__sF,__strlen_chk,android_set_abort_message, etc.) need shimming to run the.soon a normal glibc host. Diffingnm -D --undefined-onlyagainst glibc’s exports quickly identifies exactly how few symbols are actually missing. - Symbol versioning is one of the trickiest parts of cross-loading a Bionic
.sounder glibc: naively zeroingDT_VERSYMcorruptsld.so’s internal lookup tables and segfaults during relocation. The safe fix is to rewrite every.gnu.versionentry to1(unversioned) while leavingDT_VERSYMitself present, and only neutralizeDT_VERNEED/DT_VERNEEDNUM. - When a binary is protected by a deep chain of nested string-transform functions (hex decode → arithmetic mixing → jump-table byte transforms → concatenation), it’s often far faster to get the real code executing in an emulated/patched environment than to hand-reverse every stage — especially when a jump-table dispatch (
de3) is involved, since manually enumerating all its cases is tedious and error-prone. - Decoy functions (
illusion()here, looping over a fake flag-shaped string) are a deliberate reversing trap — always confirm which function’s result actually reaches thestrcmp/comparison before trusting a “found” string. - The final comparison target inside a native
strcmpis frequently itself the flag (after applying whatever transform is used on the user’s input) — oncene()’sXOR 0x10++0xEFtransform was identified, it just needed to be replayed on the freshly computed string.