HTB: Supermarket Challenge

Supermarket - HackTheBox Challenge Writeup

Challenge Information

FieldValue
Challenge NameSupermarket
CategoryMobile / Reverse Engineering
DifficultyMedium
Authord3vn0mi

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:

  1. The AES algorithm/mode in use
  2. The AES key
  3. 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.

Terminal window
# The handed-out APK was a stub — the real bundle is a sibling zip
file supermarket.apk
# supermarket.apk: empty
# Unzip the real archive with the standard HTB challenge password
unzip -P hackthebox /out/<challenge-id>.zip -d work/

Step 2: Unpack the APK and identify the native library

Terminal window
cd work
mkdir -p ex && cd ex
unzip -o ../supermarket.apk
# List the interesting contents
unzip -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

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

Terminal window
objdump -d lib/arm64-v8a/libsupermarket.so > libsupermarket.disasm
# Confirm available disassembly tooling
python3 -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 struct
from 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 NewStringUTF

Running each of the three exported functions through this harness recovered:

stringFromJNI3 -> "AES"
stringFromJNI2 -> "2mubW7SBIsaFkTXE" # 16-byte AES-128 key
stringFromJNI -> "FqVu3UluTNtSELauTRPFvq9wBdfXmbzbOgq4NS/KasE=" # base64 ciphertext

Step 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 base64
from 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

ToolPurpose
unzipExtract the password-protected challenge archive and the APK contents
stringsScan classes.dex for crypto API usage hints (AES, ECB, javax.crypto)
objdump (aarch64)Disassemble the stripped libsupermarket.so
capstonePython-side ARM64 disassembly for targeted instruction inspection
Unicorn EngineCPU emulation of the JNI functions to dynamically recover obfuscated strings
pycryptodomeAES-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 .rodata strings and long adrp/add chains weren’t cryptographically meaningful — they existed solely to defeat strings/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, hooked NewStringUTF) 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}