Backdrop

Securinets International Quals 2026
Recon
It's a Flutter app. libflutter.so, libapp.so, assets/flutter_assets/ — so the logic I care
about is in a Dart AOT snapshot, not in dex.
The lib/ directory only contains x86_64. No arm64-v8a, which means it won't install on a
phone and won't run if you force it, so I'm on an emulator from the start. That also rules out the
usual Dart recovery tooling, since blutter and unflutter are both arm64-only.
A strange freeRASP inversion: the APK includes freeRASP, which is commonly used to detect emulators and other risky runtime conditions so an app can refuse to run. This challenge does the opposite: its x86_64-only build forces me to use an emulator, the exact environment freeRASP would normally treat as suspicious.
Installing and running it gives three screens:
A splash doing "Performing necessary checks..." for about twenty seconds, a main screen called "Behind the backdrop" with a Reveal flag button, and behind that button a countdown of 35h59m, which is roughly how much of the CTF was left.
So the app will show me the flag. It just wants me to wait until the competition is over.
Killing the timer
The timer is in dex, which makes it the easy part. com.jio.backdrop.TimerActivity holds a
deadline field set to SystemClock.elapsedRealtime() + 129540000 (35h59m in ms) and ticks once a
second until it passes, then calls setResult(RESULT_OK); finish().
It uses elapsedRealtime(), monotonic since boot, so moving the system clock does nothing. It has
to be patched in memory.
Java.perform(function () {
var SystemClock = Java.use('android.os.SystemClock');
Java.choose('com.jio.backdrop.TimerActivity', {
onMatch: function (inst) {
inst.c.value = SystemClock.elapsedRealtime(); // deadline = now
console.log('[*] timer deadline patched');
},
onComplete: function () {}
});
});
frida -U Backdrop -l timer.js
One thing that will waste your afternoon: Java.choose inside setInterval does nothing. The
callback runs outside the VM thread context, so poller-style scripts fail with no error at all.
Keep it inside Java.perform, or wrap the callback body in its own.
Every time I ran the script to bypass the timer and then clicked “Reveal Flag,” the APK crashed.
The crash
I assumed I'd broken something. I blamed the emulator, then the GPU, then the script. Rewrote the hook a few times, restarted the emulator, tried another emulator. Black screen and a dead process every time.
Then I read the log.
adb logcat
[FATAL:flutter/impeller/renderer/backend/gles/render_pass_gles.cc(626)]
Check failed: result. Must be able to encode GL commands without error.
Not a tamper check, not my hook. The Flutter engine is aborting on its own.
Four names are worth knowing before going further:
- Skia — the renderer Flutter used for years.
- Impeller — its replacement, written to get rid of shader-compilation stutter. Default on Android since Flutter 3.29.
- OpenGL ES — the old GPU API on Android. Slower, but supported everywhere.
- Vulkan — the modern one. Faster, and frequently missing or broken on emulators.
Impeller draws through Vulkan when it can and falls back to OpenGL ES when it can't, which is
what an emulator gets. impeller/renderer/backend/gles/ is that fallback path.
Searching that message doesn't necessarily take you straight to the exact issue linked in the flag. There are plenty of bug reports, discussions, and other resources that can help explain the crash. The error message is one clue; the renderer configuration and what happens when you tap Reveal Flag help narrow it down too. I followed those clues through the issue tracker like any other crash and only understood the significance of the link when the flag finally rendered.
What's going on
One relevant issue is flutter/flutter#163421, whose title is the whole story: "[Impeller] App Crashes on Android Emulator when navigating to a route using a BackdropFilter with ImageFilter.blur()". Somewhere in the thread is the fix.
A BackdropFilter is a blur over whatever is painted behind it, the app is called Backdrop, and the reveal screen is a route you navigate to. Still a guess though.
It isn't the emulator's fault either, and it isn't a default gone wrong: MainActivity overrides
getFlutterShellArgs() and builds the engine's startup flags from integer arrays XORed with 90,
which decode to
--enable-impeller=true
--impeller-backend=opengles
so the app pins itself to the broken path on purpose. Combined with the x86_64-only lib/ that
forced me onto an emulator in the first place, it's built to crash on the only machine it can run
on.
So the fix is to disable Impeller and let Skia draw the blur instead. The app hardcodes the opposite, so that means patching the APK.
Patching out Impeller
Decode with apktool and change two things.
The method in smali/com/jio/backdrop/MainActivity.smali becomes a single flag:
.method public final getFlutterShellArgs()Lio/flutter/embedding/engine/FlutterShellArgs;
.locals 4
const/4 v2, 0x1
new-array v1, v2, [Ljava/lang/String;
const/4 v3, 0x0
const-string v2, "--enable-impeller=false"
aput-object v2, v1, v3
new-instance v0, Lio/flutter/embedding/engine/FlutterShellArgs;
invoke-direct {v0, v1}, Lio/flutter/embedding/engine/FlutterShellArgs;-><init>([Ljava/lang/String;)V
return-object v0
.end method
Returning empty args instead doesn't work. On 3.29 the engine just picks Impeller by default and crashes identically. I lost some time to that.
And the meta-data the Flutter issue recommends, inside <application>:
<meta-data android:name="io.flutter.embedding.android.EnableImpeller" android:value="false"/>
Rebuild, align, sign, reinstall.
References: flutter/flutter#163421,
Impeller on Android,
FlutterShellArgs
A different error
The timer hook lands, the countdown dies, the reveal screen opens, and it doesn't crash. Skia renders the blur without complaint.
Instead I get a lock icon and "An error happened."
My first thought was a tamper check tripped by the repackaging. It isn't.
Investigating
"An error happened." is a string, and strings are searchable. Grepping it in jadx gives two hits,
both in the same method, in S/d.java:
@Override // io.flutter.plugin.common.MethodChannel.MethodCallHandler
public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
r2.h.e("call", methodCall);
r2.h.e("result", result);
String str = methodCall.method;
boolean zA = r2.h.a(str, "load");
MainActivity mainActivity = (MainActivity) this.f1250b;
if (zA) {
try {
result.success(Z1.a.b(mainActivity));
} catch (Exception unused) {
result.error("unavailable", "An error happened.", null);
}
} else if (!r2.h.a(str, "sync")) {
result.notImplemented();
} else if (mainActivity.f2969b != null) {
result.error("unavailable", "An error happened.", null);
} else {
mainActivity.f2969b = result;
mainActivity.startActivityForResult(new Intent(mainActivity, (Class<?>) TimerActivity.class), 31292);
}
}
This is how Dart talks to Java in a Flutter app: Dart calls a method by name, and this handler answers. Reading it top to bottom:
- the method name is
load - all it does is return
Z1.a.b(mainActivity) - if that call throws, the user gets "An error happened."
So the screen isn't a tamper check, it's a caught exception, and whatever builds the flag is
Z1.a.b().
The other branch, sync, is the timer I'd already patched, so both halves of the challenge sit in
this one method.
Z1.a.b(Context):
digest = SHA-256( signingInfo.getApkContentsSigners()[0].toByteArray() ) // APK cert, DER
digest2 = SHA-256( "backdrop/flag/v1" ++ digest ) // AES-256 key
digest3 = SHA-256( "backdrop/flag/scramble/v1" ++ digest ) // prefix digest
// assets/flutter_assets/assets/data.bin = ["BDF1"][12-byte IV][ciphertext || 16-byte GCM tag]
plaintext = AES/GCM/NoPadding(key=digest2, iv=bytes[4..16], aad="backdrop/flag/v1")
.doFinal(bytes[16..])
return digest3 ++ plaintext // handed to Dart, which decodes and draws it
The AES key comes from the APK's signing certificate. The moment I resigned with my debug key the digest changed, the key changed, GCM failed its tag check and Dart got nothing to draw.
So it's anti-repackaging, sitting in direct tension with a crash that forces you to repackage. That's the actual trick of the challenge. The two defences are built to collide, and "An error happened." is just a failed decryption.
Bypassing the signature problem
The key comes from Signature.toByteArray(), so hook it and hand back the original certificate.
Pull that out of the untouched APK:
unzip -j backdrop.apk META-INF/CERT.RSA -d .
openssl pkcs7 -inform DER -in CERT.RSA -print_certs -out certs.pem
openssl x509 -in certs.pem -outform DER -out cert.der
920 bytes, which Frida wants as signed Java bytes:
der = open("cert.der", "rb").read()
signed = [b - 256 if b > 127 else b for b in der]
Both hooks:
Java.perform(function () {
var ORIG = [ /* the 920 signed bytes of cert.der */ ];
var Signature = Java.use('android.content.pm.Signature');
Signature.toByteArray.implementation = function () {
console.log('[*] Signature.toByteArray() -> ORIGINAL cert (' + ORIG.length + ' bytes)');
return Java.array('byte', ORIG);
};
var SystemClock = Java.use('android.os.SystemClock');
Java.choose('com.jio.backdrop.TimerActivity', {
onMatch: function (inst) {
inst.c.value = SystemClock.elapsedRealtime();
console.log('[*] timer deadline patched');
},
onComplete: function () {}
});
});
Attach during the splash, not after: the certificate is read there, not when you tap Reveal flag.
frida -U Backdrop -l solve.js
[*] hooked Signature.toByteArray
[*] Signature.toByteArray() -> ORIGINAL cert (920 bytes)
[*] timer deadline patched
Leave the REPL open, tap Reveal flag, and the countdown dies in about a second. Dart calls load,
the hook hands back the original certificate, GCM verifies, and Skia draws the flag behind a blur.
The punchline
Securinets{https://github.com/flutter/flutter/issues/163421}
The flag is the issue that explains why the reveal screen crashes on every emulator, which is the only kind of machine an x86_64-only APK runs on. You can't read it until you've already diagnosed the bug it points at.
Now the x86_64 restriction makes sense. It forces the app onto an emulator, where the selected OpenGL ES backend runs through the emulator's graphics stack and hits the bug. On a physical phone, it would use real device drivers instead of the emulator's fallback layer, so this crash would not happen. Without the crash, the reveal path works normally and the challenge becomes much easier. The author forced the emulator because the environment itself is part of the challenge.
Afterword
getFlutterShellArgs() is an ordinary Java method, so it's hookable. Returning
new FlutterShellArgs(new String[]{"--enable-impeller=false"}) from Frida in spawn mode should
disable Impeller with no repackaging at all, and with the original APK untouched the certificate
stays valid, so the signature hook becomes unnecessary too. The whole patch, resign and spoof
chain would collapse into one script.
I haven't tested it, so treat it as a direction rather than a solution.
The other way in is static, and it's the boring one: hand it to an AI agent. It will happily reverse the Dart layers and hand you the flag.
Huge shoutout to 0xjio the author of this interesting challenge.
Thanks for reading.
Back to articles