$ cat writeup.md…
$ cat writeup.md…
Securinets CTF Quals 2026
Task: Flutter Android crackme; flag is an image, not a string, behind decoy git history and a fake unlock-code premise. Solution: decrypt a signature-bound AES-256-GCM asset, reverse obfuscated x86_64 Dart AOT (xorshift32, SplitMix64 Fisher-Yates), re-render the flag from 308 RLE glyph pieces.
Android crackme for the CTF. Install the APK, enter the 6-digit unlock code, get the flag. Build with
./build.sh(never commit the signed APK or the keystore). — README shipped inside the distributed task zip
English summary: the zip contains backdrop.apk (package com.jio.backdrop, a
Flutter app with x86_64-only native libraries) plus a git repository whose
history is full of planted decoy flag strings and a honeypot AGENTS.md. The
stated goal — "enter the 6-digit unlock code" — is fiction. The real flag is not
a string anywhere in the binary: it is an image of large text, drawn glyph by
glyph on a canvas by an obfuscated Flutter CustomPainter, from a decrypted,
scrambled asset.
Before any reversing, the task tries to waste your time:
str1ngs_w0nt_s4v3_y0u (comment in src/check.py), un10ck_c0d3_1s_n0t_h3r3
(dev_flag.txt, added then deleted), g1t_h1st0ry_1s_4_tr4p (commit message
only), st4g1ng_pl4c3h0ld3r_7f3a (deploy/staging.env on a staging branch).AGENTS.md (added in commit 1fda44c) instructs automated agents
to run ping/curl against quals-i-am-ai.securinets.tn as a "connectivity
check". This is an LLM honeypot — the domain was never contacted.ymnNgX5I6IGHvrMmvJN0 hidden in the bundled securinets.jpg.TimerActivity: the MethodChannel sync method launches a timer activity
with a 129,540,000 ms (~36 h) countdown — a troll, never the intended path."An error happened.", "Performing necessary checks…", freeRASP
markers (appIntegrity, timeSpoofing, isProd, isRealDevice) and RootBeer
are props / anti-tamper noise.jadx on classes.dex: MainActivity registers MethodChannel "app/platform";
the load handler returns Z1.a.b(ctx). Z1.a keeps its string constants
XOR-obfuscated with 0x5A; they decode to:
"backdrop/flag/v1" — KDF label and AES-GCM AAD"backdrop/flag/scramble/v1" — second KDF label"BDF1" — asset magic"data.bin" — asset nameZ1.a.b() computes certDigest = SHA256(signing-cert DER) and parses
assets/flutter_assets/data.bin as:
[ BDF1 (4 bytes) ][ IV (12 bytes) ][ AES-256-GCM ciphertext+tag ]
aes_key = SHA256("backdrop/flag/v1" || certDigest), AAD = "backdrop/flag/v1"key2 || plaintext where
key2 = SHA256("backdrop/flag/scramble/v1" || certDigest) (32 bytes) and
plaintext is 19,756 bytes.Because the key material is bound to the app's own signing certificate, you must
extract the certificate from the APK's signature block (CERT.RSA payload →
signer.der) and recompute the digest yourself:
certDigest = b3da89ba08a26c12f98fe62b99ed8c0ae27941ff1d6e7d2a4ec1a447e2b7d2eb
Cross-check: the same digest is embedded in the Dart snapshot as base64
s9qJugiibBL5j+Yrme2MCuJ5Qf8dbn0qTsGkR+K30us=. The derived scramble key is
key2 = dc5bac1dbd4f1db04bd58ca89017361dafe740ec7f389e8d9ab29adc4fe7fdca.
Plaintext layout: [E: 4 bytes][scrambled payload: 19,752 bytes] with
E = u32LE(plaintext[0:4]) = 0x5BCDE70B. Naive XOR/AES/ChaCha20 attempts with
key2 directly produce nothing — the real algorithm lives in Dart AOT.
libapp.so is a Dart 3.7.0 AOT snapshot (snapshot hash
d91c0e6f35f0eb2e44124e8f42aa44a7, Flutter 3.29.x) with obfuscated class names
(Yp, Xp, …). Blutter and most Dart snapshot tools are arm64-only; for
x86_64 the working setup is r2flutter 0.5.1 against a locally built radare2
6.2.4 — the snapshot parses cleanly (classes, strings, ObjectPool).
Pool addressing: Dart code loads pool constants with [r15 + disp]; with entry
size 8 and entries_offset = 0x10, the entry index is (disp - 7) / 8 (all
observed displacements are ≡ 7 mod 8).
Three key functions:
0x192b88 — xorshift32 keystream "unscrambler" (the payload deobfuscator):
state = seed ? seed : 0x6D2B79F5
per byte i:
x = state
x ^= x << 13; x ^= x >> 17; x ^= x << 5 # all mod 2^32
ks = (x >> 7) & 0xFF
out[i] = in[i] ^ ks
state = (x ^ (out[i] * 0x9E3779B1)) & 0xFFFFFFFF
if state == 0: state = 0x6D2B79F5
The feedback mixes the output byte, so the state chain runs over the plaintext — which makes the transform an involution (the same routine works in both directions).
0x192ac8 — SplitMix64 next() over a 64-bit state field (golden-gamma
constants 0x9E3779B97F4A7C15, 0xBF58476D1CE4E5B9, 0x94D049BB133111EB,
shifts 30/27/31). Critical detail: the app keeps only the low 32 bits
of every output (movl %eax,%eax before each use).
0x191678 — Yp.paint: a CustomPainter. This is the consumer of
everything below — the flag image is painted here.
The painter consumes the unscrambled buffer B as:
u16LE values (894, 332, 28, 11) — canvas 894×332, glyph
grid 28×11 = 308 pieces, each a 32×32 bitmap (grid is 896×352; the painter
clips to 894×332 — irrelevant for static rendering).v1 = varint() is the run count for that row; then for each run
c = 0..v1-1: skip = varint(), len = varint(), and the run's start column
is run_index + skip; set bits [start, start+len). The varint stream is
consumed exactly (19752/19752 bytes) — a strong correctness check.list1[i] = next() & 7 — 308 calls;list2 = [0..307]; Fisher–Yates: for j = 307..1: k = next() % (j+1); swap(list2[j], list2[k]) — 307 calls.i is drawn at grid cell list2[i] (row = list2[i]/28,
col = list2[i]%28). Before drawing, the piece is transformed with
flags = C8[list1[cell]] (indexed by the destination cell), where the
constant table C8 (pool entry 5515) = [0,1,2,3,4,6,5,7] (entries 3–7
verified as Smi constants 3, 4, 6, 5, 7; entries 0–2 are the ints 0, 1, 2).
Transform ops by flag bit: weight 4 = transpose, weight 2 = horizontal flip,
weight 1 = vertical flip, applied in the order T, then H, then V.w32 = u32LE(key2[0:4]) ^ E = 0x4661BCD7 (xorshift32);
w64 = u64LE(key2[8:16]) ^ E (SplitMix64 state).acc = acc<<8 | byte[i]) looks big-endian but actually yields little-endian
— the first byte read ends up in the high byte after 8 shifts of a zero
accumulator. Empirically verified by testing both variants and scoring the
render.MainActivity.getFlutterShellArgs() forces
--enable-impeller=true --impeller-backend=opengles. Flutter issue #163421
is "[Impeller] App Crashes on Android Emulator when navigating to a route
using a BackdropFilter with ImageFilter.blur()" — the app is engineered to
hit exactly that bug, which is why every dynamic-analysis attempt crashed. The
challenge name Backdrop refers to BackdropFilter.#!/usr/bin/env python3 import hashlib from cryptography.hazmat.primitives.ciphers.aead import AESGCM # Signing-cert DER extracted from the APK's v2/v3 signature block (CERT.RSA payload) cert = open("signer.der", "rb").read() cert_digest = hashlib.sha256(cert).digest() kdf = lambda label: hashlib.sha256(label.encode() + cert_digest).digest() aes_key = kdf("backdrop/flag/v1") # AES-256-GCM key key2 = kdf("backdrop/flag/scramble/v1") # scramble key returned to Dart data = open("data.bin", "rb").read() assert data[:4] == b"BDF1" plain = AESGCM(aes_key).decrypt(data[4:16], data[16:], b"backdrop/flag/v1") open("key2.bin", "wb").write(key2) # 32 bytes open("data_plain.bin", "wb").write(plain) # 19756 bytes
Full static reconstruction of the Dart painter (0x192b88 + 0x192ac8 + 0x191678):
#!/usr/bin/env python3 import struct, zlib M32 = 0xFFFFFFFF M64 = (1 << 64) - 1 # ---- 0x192b88: xorshift32 keystream unscrambler (feedback over OUTPUT bytes) def unscramble(buf: bytes, seed: int) -> bytes: state = seed & M32 if state == 0: state = 0x6D2B79F5 out = bytearray(len(buf)) for i, b in enumerate(buf): x = state x ^= (x << 13) & M32 x ^= x >> 17 x ^= (x << 5) & M32 x &= M32 o = b ^ ((x >> 7) & 0xFF) out[i] = o state = (x ^ (o * 0x9E3779B1)) & M32 if state == 0: state = 0x6D2B79F5 return bytes(out) # ---- 0x192ac8: SplitMix64 — the app keeps only the LOW 32 BITS of every output class R32: def __init__(self, seed: int): self.s = seed & M64 def next(self) -> int: z = (self.s + 0x9E3779B97F4A7C15) & M64 self.s = z z ^= z >> 30; z = (z * 0xBF58476D1CE4E5B9) & M64 z ^= z >> 27; z = (z * 0x94D049BB133111EB) & M64 z ^= z >> 31 return z & M32 key2 = open("key2.bin", "rb").read() # 32 B, from stage 1 plain = open("data_plain.bin", "rb").read() # 19756 B, from stage 1 E = int.from_bytes(plain[0:4], "little") # 0x5BCDE70B w32 = int.from_bytes(key2[0:4], "little") ^ E # 0x4661BCD7 w64 = int.from_bytes(key2[8:16], "little") ^ E # LITTLE-endian! B = unscramble(plain[4:], w32) canvas_w, canvas_h, gw, gh = struct.unpack_from("<4H", B, 0) # 894, 332, 28, 11 N = gw * gh # 308 glyph pieces rng = R32(w64) tf_idx = [rng.next() & 7 for _ in range(N)] # list1 perm = list(range(N)) # Fisher-Yates, descending for j in range(N - 1, 0, -1): k = rng.next() % (j + 1) # low-32 modulo perm[j], perm[k] = perm[k], perm[j] # ---- varint-RLE glyph masks: 32 rows/piece, runs at start = run_index + skip pos = 8 def varint() -> int: global pos val = shift = 0 while True: by = B[pos]; pos += 1 val |= (by & 0x7F) << shift if not (by & 0x80): return val shift += 7 pieces = [] for _ in range(N): m = bytearray(1024) for g in range(32): # row g v1 = varint() # run count for this row for c in range(v1): skip = varint() start = c + skip # absolute column ln = varint() for t in range(start, start + ln): m[g * 32 + t] = 1 pieces.append(bytes(m)) assert pos == len(B), (pos, len(B)) # exact consumption: 19752/19752 # ---- placement + transforms (paint method 0x191678, obfuscated class Yp) C8 = [0, 1, 2, 3, 4, 6, 5, 7] # pool entry 5515 def transform(m: bytes, flags: int) -> bytes: cur = bytearray(m) if flags & 4: # transpose cur = bytes(cur[32 * j + i] for i in range(32) for j in range(32)) if flags & 2: # horizontal flip cur = bytes(cur[32 * i + (31 - j)] for i in range(32) for j in range(32)) if flags & 1: # vertical flip cur = bytes(cur[32 * (31 - i) + j] for i in range(32) for j in range(32)) return cur stride, rows = gw * 32, gh * 32 img = bytearray(stride * rows) for i in range(N): cell = perm[i] # piece i -> grid cell perm[i] br, bc = divmod(cell, gw) m = transform(pieces[i], C8[tf_idx[cell]]) for r in range(32): row = br * 32 + r img[row * stride + bc * 32 : row * stride + bc * 32 + 32] = m[r * 32:(r + 1) * 32] # ---- render PNG (8-bit grayscale, white background, black ink), 3x scale def write_png(path: str, scale: int): W2, H2 = stride * scale, rows * scale raw = bytearray() for r in range(rows): line = bytes([0]) + bytes( 0 if img[r * stride + c] else 255 for c in range(stride) for _ in range(scale)) for _ in range(scale): raw += line def chunk(t, d): c = struct.pack(">I", len(d)) + t + d return c + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF) png = b"\x89PNG\r\n\x1a\n" png += chunk(b"IHDR", struct.pack(">IIBBBBB", W2, H2, 8, 0, 0, 0, 0)) png += chunk(b"IDAT", zlib.compress(bytes(raw), 9)) png += chunk(b"IEND", b"") open(path, "wb").write(png) write_png("backdrop_flag_render.png", 3) print("rendered -> backdrop_flag_render.png")
The script consumes the varint stream exactly (19752/19752 bytes — parser model
confirmed) and renders an 896×352 black-and-white image: three lines of large
monospace text. The text is the flag in the usual event format
(Securinets{REDACTED} here — see the format note in the task header): the body
is the GitHub URL of flutter/flutter issue #163421.
The thematic cross-check closes the loop: issue #163421 is the Impeller
BackdropFilter crash on Android emulators, and the APK force-feeds exactly the
flag combination (--enable-impeller=true --impeller-backend=opengles) that
triggers it. The challenge is a playable version of its own crash report —
which is also why dynamic analysis was a dead end and static Dart AOT reversal
was the only path.
Verified render artifacts from the solve session: pure_scheme.png with
readability crops line1_zoom.png, line2_zoom.png, line3_zoom.png.
Use this approach when:
libapp.so — Blutter/Doldrums won't
work; use r2flutter 0.5.1 on a current radare2 build instead.d91c0e6f…) pins Dart 3.7.x and obfuscated class names
(Yp, Xp) mean no symbol recovery — map pool loads arithmetically:
index = (disp - 7) / 8 for [r15 + disp].SHA256(label || SHA256(signing cert)) — the
signature binding forces recomputing the cert digest from the APK itself.CustomPainter →
suspect the flag is an image.& 7 list and a Fisher–Yates shuffle →
recover the exact call order and the truncation width; low-32 vs full-64
changes the permutation entirely.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar