HTB: Supermarket Challenge
Supermarket - HackTheBox Challenge Writeup
Challenge Information
| Field | Value |
|---|---|
| Challenge Name | Supermarket |
| Category | Mobile / Reverse Engineering |
| Difficulty | Medium |
| Author | d3vn0mi |
Description
My supermarket list is too big and I only have 50$. Can you help me get the Discount code?
Players are handed an Android APK and asked to recover a hidden “discount code” — the flag — buried somewhere inside the app’s logic.
Solution Overview
The challenge distribution was a small trap in itself: the supermarket.apk handed out directly was a 0-byte stub. The real artifact was a password-protected archive (/out/<id>.zip, password hackthebox) sitting alongside it.
Once unpacked, the actual APK’s interesting logic wasn’t in the Java/Kotlin layer at all — the DEX bytecode just glued together the output of three JNI (Java Native Interface) functions exported by a stripped arm64-v8a native library, libsupermarket.so, and fed them into javax.crypto for an AES decryption. So the real target was reverse engineering the native library to recover:
- The AES algorithm/mode in use
- The AES key
- The base64-encoded ciphertext
The native strings were deliberately obfuscated — every output character was computed as byte_A XOR byte_B, where byte_A and byte_B came from two different offsets in .rodata, assembled through dozens of adrp/add pointer-arithmetic instructions rather than being stored as plain strings. Rather than hand-tracing that arithmetic through the disassembly, the fastest path was to emulate the JNI functions directly with Unicorn and simply capture what they handed back to the JNI environment.
Once the three JNI outputs were recovered, decrypting the ciphertext with the recovered key under AES-128-ECB revealed the flag directly.
Key Steps
Step 1: Recover the real APK from the password-protected archive
The distributed supermarket.apk was a decoy/placeholder (0 bytes). The actual challenge files lived in a zip that required the classic HTB default password.
# The handed-out APK was a stub — the real bundle is a sibling zipfile supermarket.apk# supermarket.apk: empty
# Unzip the real archive with the standard HTB challenge passwordunzip -P hackthebox /out/<challenge-id>.zip -d work/Step 2: Unpack the APK and identify the native library
cd workmkdir -p ex && cd exunzip -o ../supermarket.apk
# List the interesting contentsunzip -l ../supermarket.apk | grep -iE 'dex|assets|lib'This showed a classes.dex plus lib/arm64-v8a/libsupermarket.so — a stripped ARM64 shared object exporting three JNI_OnLoad-style native methods (stringFromJNI, stringFromJNI2, stringFromJNI3).
Step 3: Confirm the crypto usage in the DEX
strings -n 4 classes.dex | grep -iE 'AES/|CBC|ECB|javax.crypto'This confirmed the app decrypted a value at runtime using javax.crypto.Cipher with an AES key/ciphertext sourced from the three native calls — i.e., the app never stores the plaintext flag or the AES parameters as plain Java strings; everything of interest is computed inside the native library.
Step 4: Disassemble the native library
objdump -d lib/arm64-v8a/libsupermarket.so > libsupermarket.disasm
# Confirm available disassembly toolingpython3 -c "import capstone; print([a for a in dir(capstone) if 'ARCH' in a])"Each stringFromJNI* function built its return string byte-by-byte at runtime from two .rodata blobs XORed together, with the offsets computed via long chains of adrp/add pairs — not something worth hand-decoding statically.
Step 5: Emulate the JNI functions with Unicorn to recover the strings
Instead of manually resolving every adrp/add pair, the ELF was mapped into a Unicorn emulator, with a minimal fake environment:
import structfrom unicorn import *from unicorn.arm64_const import *
# Map the .so's segments into emulator memory# Stub out libc calls (malloc/memcpy/etc.) via GOT trap hooks# Fake a minimal JNIEnv* so the target function can call NewStringUTF()# Hook NewStringUTF to capture the constructed string instead of letting# the emulator crash trying to actually create a Java object
def hook_new_string_utf(uc, address, size, user_data): # x1 holds the char* argument to NewStringUTF(JNIEnv*, const char*) ptr = uc.reg_read(UC_ARM64_REG_X1) data = uc.mem_read(ptr, 64).split(b'\x00')[0] print("Recovered string:", data)
# ... map segments, set up stack, set PC to each stringFromJNI* entry point,# emulate until return, and read out the value passed to NewStringUTFRunning each of the three exported functions through this harness recovered:
stringFromJNI3 -> "AES"stringFromJNI2 -> "2mubW7SBIsaFkTXE" # 16-byte AES-128 keystringFromJNI -> "FqVu3UluTNtSELauTRPFvq9wBdfXmbzbOgq4NS/KasE=" # base64 ciphertextStep 6: Decrypt with AES-128-ECB
With the algorithm, key, and ciphertext all recovered, the mode was confirmed as ECB (no IV was ever produced by the native code, and a CBC attempt with the same key produced garbage):
import base64from Crypto.Cipher import AES
key = b"2mubW7SBIsaFkTXE"ct = base64.b64decode("FqVu3UluTNtSELauTRPFvq9wBdfXmbzbOgq4NS/KasE=")
cipher = AES.new(key, AES.MODE_ECB)pt = cipher.decrypt(ct)
print(pt) # -> HTB{REDACTED}Decryption yielded the flag directly (PKCS#7-padded plaintext).
Tools Used
| Tool | Purpose |
|---|---|
unzip | Extract the password-protected challenge archive and the APK contents |
strings | Scan classes.dex for crypto API usage hints (AES, ECB, javax.crypto) |
objdump (aarch64) | Disassemble the stripped libsupermarket.so |
capstone | Python-side ARM64 disassembly for targeted instruction inspection |
Unicorn Engine | CPU emulation of the JNI functions to dynamically recover obfuscated strings |
pycryptodome | AES-128-ECB decryption of the recovered ciphertext |
Key Learnings
- Native code is often used purely to hide static analysis, not to add real complexity. The XOR-assembled
.rodatastrings and longadrp/addchains weren’t cryptographically meaningful — they existed solely to defeatstrings/simple decompilation. Emulating the actual instructions was far faster than manually reconstructing the pointer arithmetic by hand. - Emulation beats static tracing for obfuscated constant construction. Standing up a minimal Unicorn harness (mapped segments, stubbed libc, faked
JNIEnv, hookedNewStringUTF) let the target code compute its own obfuscated output instead of requiring it to be reverse-engineered by inspection. - Check for smoke-and-mirrors in challenge distribution. A 0-byte “decoy” file alongside a real, password-protected archive is a common way to catch players who don’t verify what they were actually given before diving into analysis.
- Confirm the block cipher mode empirically, not by assumption. With no IV ever produced by the native code, ECB was the only mode that produced valid plaintext — a quick sanity check against CBC before committing to that assumption would have saved a dead end.
Flag
HTB{REDACTED}