HTB: Angler Challenge

Angler - HackTheBox Challenge Writeup

Challenge Information

FieldValue
NameAngler
CategoryMobile / Reversing (Misc)
DifficultyMedium
Authord3vn0mi

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 points
from androguard.core.apk import APK
a = 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

Terminal window
unzip -o -q Angler.apk 'lib/*' -d ext
file ext/lib/x86_64/libangler.so

Interesting strings turned up directly in .rodata:

Terminal window
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 there

3. Disassemble getInfo and trace the call graph

Terminal window
objdump -d --start-address=0x49e00 --stop-address=0x4a400 \
ext/lib/x86_64/libangler.so

Java_com_..._getInfo calls two mangled C++ symbols:

call _Z8illusionPKc ; decoy — loops over a fake "HTB{REDACTED}" 100 times, does nothing useful
call _Z2nePKc ; the REAL comparison routine

Disassembling _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 input

So 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 the x-separated numbers, computes char = (value + 10) / 2 per token
  • a2(...) — decodes another hex blob (string3) and computes (byte + 91) % 128, then combines with the w4 output
  • a3(...) — calls de3(), a byte-transform routine driven by a jump table keyed on character class (lower/upper/digit)
  • a4(...) — calls re3(), another jump-table-based transform, and concatenates the final pieces
Terminal window
# Map every constant to its raw string content
b42d3 "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

Terminal window
# Diff the .so's undefined dynamic symbols against glibc's exports
nm -D --undefined-only ext/lib/x86_64/libangler.so | sed 's/@LIBC//' > need.txt
comm -23 <(sort need.txt) <(sort have.txt)
# => only 4 symbols missing: __errno, __sF, __strlen_chk, android_set_abort_message

Built a tiny shim providing exactly those four with as/ld:

.text
.globl __errno
__errno:
leaq myerrno(%rip), %rax
ret
.globl __strlen_chk
...
.data
myerrno: .long 0
__sF: .zero 0x1000
Terminal window
as shim.s -o shim.o
ld -shared -o libshim.so shim.o

Repointed the library’s NEEDED entries from Bionic to glibc equivalents:

Terminal window
patchelf --replace-needed liblog.so libshim.so libangler_patched.so
patchelf --replace-needed libc.so libc.so.6 libangler_patched.so
patchelf --replace-needed libm.so libm.so.6 libangler_patched.so
patchelf --replace-needed libdl.so libpthread.so.0 libangler_patched.so
patchelf --set-rpath /tmp/work libangler_patched.so

First attempt still failed — glibc rejected the Bionic LIBC symbol version:

OSError: /lib/x86_64-linux-gnu/libc.so.6: version `LIBC' not found

Blindly 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, 4226
for 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 ctypes
lib = ctypes.CDLL('/tmp/work/lib4.so', mode=ctypes.RTLD_GLOBAL)
# LOADED OK

6. Call the real routine and apply the final transform

lib._Z2a1v.restype = ctypes.c_char_p
raw = 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 NEEDED entries and RPATH
  • as / ld (GNU binutils) — building a minimal glibc shim for missing Bionic symbols
  • gdb — diagnosing the ld.so segfault 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 .so on a normal glibc host. Diffing nm -D --undefined-only against 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 .so under glibc: naively zeroing DT_VERSYM corrupts ld.so’s internal lookup tables and segfaults during relocation. The safe fix is to rewrite every .gnu.version entry to 1 (unversioned) while leaving DT_VERSYM itself present, and only neutralize DT_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 the strcmp/comparison before trusting a “found” string.
  • The final comparison target inside a native strcmp is frequently itself the flag (after applying whatever transform is used on the user’s input) — once ne()’s XOR 0x10 + +0xEF transform was identified, it just needed to be replayed on the freshly computed string.