SecureVault, Mobile
safctf{d9d4c9e7-0f00-48dd-937d-0337bfb14baa}The shape of it
A Flutter AOT app, package com.example.securevault, about 47 MB. It shows a lock, the words Device integrity required, and a button: Run integrity scan. Tapped on a genuinely compromised device, it refuses. Tapped on a device that has been made to look clean, it decrypts the secret and writes it to a sqflite database at /data/data/com.example.securevault/databases/vault.db, which only root can read.
The challenge is therefore two problems in sequence: defeat the environment checks, then read a root-only file. The second is straightforward once the device is rooted. The first is where the difficulty sits.
What the checks actually were
The Java side had already been recovered with JADX in earlier work, and re-reading it proved more reliable than relying on the summary. MainActivity registers a MethodChannel named securevault/rootcheck with six methods, each returning the string "0" for clean or "1" for compromised:
| method | what it looks at |
|---|---|
checkRootPackages |
getPackageInfo on five Magisk/SuperSU package names |
checkSuBinary |
new File(...).exists() over eight su paths |
checkEmulator |
Build.FINGERPRINT/MODEL/BRAND/DEVICE/PRODUCT/HARDWARE/MANUFACTURER plus ro.kernel.qemu |
checkDebuggableBuild |
Build.TYPE in userdebug/eng, dev-keys/test-keys, ro.debuggable, ro.secure |
checkTestKeys |
Build.TAGS contains test-keys |
signingCertSha256 |
SHA-256 of the DER signing certificate |
The certificate digest was confirmed independently with apksigner rather than taken on trust:
Signer #1 certificate DN: CN=SecureVault CTF, O=SecureVault, C=KE
Signer #1 certificate SHA-256 digest: a4cb6936b9be0310fd7f0ae72f41a804b81f3272816e5323701bb50f83d67f7dThe trap worth naming
That sixth method is not a detection check. It feeds a value into Dart's _deriveKey, so the certificate digest is part of the encryption key material.
This means patching and re-signing the APK breaks decryption unless the digest is handled. A re-signed APK has a different certificate, so signingCertSha256 returns a different digest, and the key derived from it decrypts to garbage. Earlier work had already reached the same conclusion from static analysis, and it is the single fact that shaped the whole approach.
The fix is to patch all six methods at once: return "0" from the five probes, and hardcode the original digest into signingCertSha256 so the re-signed APK still reports the digest the Dart side expects. Re-signing is then harmless.
Getting a device to run it on
The blocker was the environment rather than the idea. The Android system image download
had to be completed (an earlier attempt died at 57%, and sdkmanager discards partials, so
it restarted from zero), then a split SDK had to be repaired: ~/android-sdk held the
emulator and system image but had no platform-tools, so the emulator refused to start with
Broken AVD system path. Symlinking the working adb and companions across from
~/Android/Sdk unblocked it.
The ATD image, being stripped for headless testing, has no dynamic linker:
/lib64/ld-linux-x86-64.so.2 does not exist. The glibc frida-server obtained first died
instantly with a bare Aborted and no message. The Android build of frida-server is the
correct artefact, since it is built against Bionic. Executing anything from /data
additionally required setenforce 0.
The intended route was runtime hooking with Frida, and a hook (payloads/bypass.js) was
prepared to return "0" from the five probes while explicitly letting
signingCertSha256 run untouched. However, frida-server would not stay up. It aborted
repeatedly, and host RAM fell to about 158 MiB at the worst point. With root already
available on the device, patching the smali directly was strictly simpler and required no
injected code at all.
The patch
Six methods in MainActivity.smali, each reduced to a constant return:
.method private final checkTestKeys()Ljava/lang/String;
.registers 2
const-string v0, "0"
return-object v0
.end method
.method private final signingCertSha256()Ljava/lang/String;
.registers 2
const-string v0, "a4cb6936b9be0310fd7f0ae72f41a804b81f3272816e5323701bb50f83d67f7d"
return-object v0
.end methodOne detail fails silently if missed: .registers 1 is wrong for an instance method,
because this occupies the final register. The first patch used 1 and was corrected to
2 before building.
Then apktool b, zipalign, and apksigner sign with a debug key. Verification confirmed
the signature had indeed changed:
Signer #1 certificate SHA-256 digest: 3e197ed69346713e5cd221a9b6fd7ec73d7275fc03a7198e626e3db90d85cc1dThis is precisely why the hardcoded digest in the smali matters.
Getting the flag out
Boot the emulator, adb root, install the patched APK, launch it, tap the button at
roughly (160, 500) on the 320x640 screen. The app reported success:
Integrity verified. The secret has been written to the secure store on this device.
Then, as root:
$ adb shell cat /data/data/com.example.securevault/databases/vault.db > loot/vault.db
$ sqlite3 loot/vault.db "select secret from vault"
safctf{d9d4c9e7-0f00-48dd-937d-0337bfb14baa}The stored value is plaintext, not ciphertext. The encrypt/PointyCastle layer is applied
to the in-memory path and the app writes the decrypted secret, so no offline decryption was
needed once the checks passed. The key-derivation question, which had consumed a
considerable amount of earlier effort, therefore turned out not to be the obstacle at all:
the cert digest mattered only because the app re-hashes on the patched build.
Alternative approach
The smali patch can be performed first, skipping Frida entirely. A plan centred on runtime hooking met persistent environmental resistance: a stripped image with no linker, SELinux blocking exec, then a server that would not stay alive under memory pressure. All of that was avoidable. Patching the six methods requires nothing running inside the target process.
The emulator was also lost once to the OOM killer, caused by running a memory-hungry apktool build while a 1 GB emulator was live and host RAM had fallen under 200 MiB. Building before booting avoids that.
The substantive lesson concerns the trap. A challenge that punishes re-signing appears to force runtime hooks, and nearly did so. Hardcoding the single value that would otherwise break decryption yielded a static patch that is both simpler and more robust than the dynamic route.
