← Back to Writeups
CUSTOMEasyReversing

droid

XESXOR9/2/20264 min read
#reversing#custom#easy#apk_decompilation#dex_analysis#known_plaintext_reasoning#per_byte_bruteforce#xor_key_recovery

droid

Platform: CUSTOM | Category: Reversing | Type: Challenge | Difficulty: Easy | OS: N/A | **Author: XESXOR Techniques: apk_decompilation, dex_analysis, known_plaintext_reasoning, per_byte_bruteforce, xor_key_recovery

Summary

Walkthrough of the CUSTOM Reversing droid. Task: Android APK crackme with a single MainActivity that XOR-transforms an encrypted flag buffer based on movement input over an ASCII maze. Solution: recognized each flag byte is independently XORed

Description

An Android crackme. Type a string into the box and tap solve; a correct input shows the flag as a Toast, otherwise "Nope".

An APK crackme (droid.apk). The single MainActivity.a(String str) method interprets user input as movement over an embedded ASCII maze and uses those moves to decrypt an embedded flag buffer. Goal: recover the flag.

Recon

bash
unzip droid.apk          # only classes.dex + resources, NO lib/ (no native code)
jadx -d out/ droid.apk   # -> out/sources/spbctf/re/ez/MainActivity.java

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Everything lives in one method, MainActivity.a(String str).

Analysis

Two base64 blobs are embedded in the method:

  • Grid / maze (bArrDecode): base64-decoded, then every byte XORed with 202 (0xCA). Decodes to a 22-column ASCII maze (11 rows, 245 bytes) with o (start) at row 1 / col 1 and x (goal) at row 8 / col 1. Walkable cells have byte value 32 (space), 120 ('x'), or 111 ('o').

    6enp6enp6enp6enp6enp6enp6enp6eml6urq6unq6erp6urq6urq6urq6enp6unq6enp6unq6erp6enq6enp6unp6erp6erq6urq6urq6urq6urp6unp6enq6erq6unp6enp6unq6enp6unp6enp6unq6erq6urq6unp6urq6erq6unp6erp6unp6enp6erp6enp6enp6enp6enq6urp6urq6urq6urq6urq6erqsunp6unp6erp6unp6enp6enp6enq6enp6erq6unq6erq6urq6urq6urq6urp6enp6enp6enp6enp6enp6enp6enp6ek=
    

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  • Encrypted flag (bArrDecode2): base64-decoded to 41 bytes.

    h4SWl4CSvbIOBxLhtau1qZmnq5WODwgBq5WoorSpr6KZr7WZowcVv7s=
    

The input string drives a virtual position (i2*22)+i3, starting at i2=1, i3=1. For each input character at index i4, the byte enc[i4] is XORed 34 times with (k * mult) & 0xff for k = 1..34, where mult depends on the move:

charASCIIactionmultfolded XOR
D68i2++ (down)239113
R82i3++ (right)9767
L76i3-- (left)65227
U85i2-- (up)65227
else—no move65227
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

After each move, the landed grid cell is checked:

  • If walkable (32/120/111): enc[i4] is XORed 34 more times with (k*67)&0xff → folds to 133 (walk).
  • Otherwise (wall): the entire buffer is corrupted with a (k*69) table (the failure branch), destroying the flag.

Win condition: after all input is processed, if grid[(i2*22)+i3] == 120 ('x'), the Toast shows new String(bArrDecode2); otherwise "Nope".

Key insight

The 34-fold XOR loops are constants. Folding (k*mult)&0xff over k=1..34 gives a single byte per move. When every step lands on a walkable cell, the whole transform on flag byte i collapses to: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

flag[i] = enc0[i] ^ move_xor(move_i) ^ 133

with move_xor: D=113, R=67, L=227, U=227 (L and U are identical).

So there are only 3 distinct plaintext candidates per byte (D / R / L≡U). Brute-forcing all positions and picking the printable/coherent option yields a unique readable English flag. No maze pathfinding is required — direct per-byte XOR inversion recovers the flag.

Pitfall (failed approach)

Modeling this as a shortest-path BFS maze solve is a dead end: the shortest walk from o to x is 44 moves, but the flag buffer is only 41 bytes, so the loop would index out of bounds. The intended solve is byte-wise XOR inversion, not maze traversal. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

python
#!/usr/bin/env python3
import base64

enc = bytearray(base64.b64decode("h4SWl4CSvbIOBxLhtau1qZmnq5WODwgBq5WoorSpr6KZr7WZowcVv7s=\n"))

def fold(mult):
    v = 0
    for k in range(1, 35):
        v ^= (k * mult) & 0xff
    return v

WALK = fold(67)                                   # 133
MOVE = {"D": fold(239), "R": fold(97), "L": fold(65), "U": fold(65)}
# MOVE = {'D': 113, 'R': 67, 'L': 227, 'U': 227}

# Per-byte brute force: for each byte pick the move whose decrypt is printable
# (D and R give distinct plaintext; L/U are identical).
flag = bytearray(len(enc))
for i, b in enumerate(enc):
    for mv in ("D", "R", "L"):
        cand = b ^ MOVE[mv] ^ WALK
        if 32 <= cand < 127:                      # printable ASCII
            flag[i] = cand
            # prefer the readable candidate; ties resolved by staying in ascii
print(bytes(flag).decode())
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Deterministic verification with the recovered move sequence:
moves = "DDDDDDRRLLLRRDRRRRRDDLLLDDRRRRRRRRRRRLLRR"   # 41 chars
out = bytearray(enc)
for i, m in enumerate(moves):
    out[i] ^= MOVE[m] ^ WALK
assert bytes(out) == b"spbctf{REDACTED}"
print(bytes(out).decode())

The move sequence that reproduces the flag exactly (and is a valid walk to the x goal in the maze) is:

DDDDDDRRLLLRRDRRRRRDDLLLDDRRRRRRRRRRRLLRR

A Python reimplementation of the per-byte transform reproduces the flag byte-for-byte. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Takeaways / Lessons

  • Mastering apk_decompilation, dex_analysis, known_plaintext_reasoning, per_byte_bruteforce, xor_key_recovery was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-07-02 • CUSTOM • droid • Difficulty: Easy