CourierDrop (OSC Regional 2026) — 4-Stage Android RE
A courier logistics app with four layers of secrets: an obfuscated dispatch code, a JNI callback that hides its output, anti-debug guards protecting encrypted notes, and an HMAC-signed local HTTP verifier that hands out the final flag.
Stage 1 — dispatch code → session token (FLAG 1)
The first screen asks for a "dispatch code" (hint format: DRV-XXX-YYYY). Decompiling with jadx gives you DispatchValidator.java, and the validation logic is immediately readable:
public final class DispatchValidator {
private static final byte[] ENCODED = {
0x06, 0x10, 0x14, 0x6F,
0x0C, 0x10, 0x7B, 0x6F,
0x70, 0x72, 0x70, 0x74
};
public final boolean validate(String input) {
String s = input.trim().toUpperCase();
if (s.length() != ENCODED.length) return false;
for (int i = 0; i < ENCODED.length; i++) {
if (s.charAt(i) != ((ENCODED[i] & 0xFF) ^ 66)) return false; // 66 = 0x42
}
return true;
}
}
The formula is right there: each character of the input must equal (ENCODED[i] & 0xFF) ^ 0x42. It's directly reversible — apply the same XOR to each byte:
ENCODED = [0x06, 0x10, 0x14, 0x6F, 0x0C, 0x10, 0x7B, 0x6F, 0x70, 0x72, 0x70, 0x74]
print(''.join(chr(b ^ 0x42) for b in ENCODED))
# → DRV-NR9-2026
The dispatch code is DRV-NR9-2026.
Knowing the code isn't enough — FLAG 1 isn't a plain string. The app runs BlobDecryptor.decryptBlob(blob1, key), where the key is SHA-256("DRV-NR9-2026" + "|courier|salt|2026"). The result — the session token — is stored in EncryptedSharedPreferences and is also visible in the Diagnostics screen after activation. I captured it at the decryption call with Frida:
Java.perform(() => {
// option 1: bypass validation — any input will activate
const DV = Java.use("com.courierdrop.app.crypto.DispatchValidator");
DV.validate.implementation = function(code) {
console.log("[*] input:", code, "→ bypassed");
return true;
};
// capture FLAG1 from the blob decryption output
const BD = Java.use("com.courierdrop.app.crypto.BlobDecryptor");
BD.decryptBlob.implementation = function(blob, key) {
const result = this.decryptBlob(blob, key);
if (result !== null)
console.log("[FLAG1]", Java.use("java.lang.String").$new(result, "UTF-8"));
return result;
};
// backup: hook SessionManager to catch the saved value
const SM = Java.use("com.courierdrop.app.data.SessionManager");
SM.saveSession.implementation = function(session) {
console.log("[FLAG1] session_id:", session.sessionId.value);
return this.saveSession(session);
};
});
native package shows up as p004native in jadx output (the number varies by version). When writing Frida hooks, use the real class name from the APK, not the jadx alias — find it in the d2 field of the @Metadata Kotlin annotation: Lcom/courierdrop/app/native/NativeAttestation;.
Stage 2 — native attestation via JNI callback (FLAG 2)
Stage 2 is the package scan flow. The UI shows a progress indicator — "Validating label…", "Checking chain-of-custody signature…", "Device attestation in progress…" — and marks the stop as Verified. FLAG 2 never appears on screen.
What actually happens: NativeAttestation.processScan(sessionId, awbId) runs in C++ via JNI. The native side derives a key from the session token (SHA-256(session_id + "|attest|salt|2026")), decrypts blob2 from .rodata, and sends the attestation string back through a JNI CallVoidMethod to onScanSuccess(attestation). The Diagnostics screen shows only a short truncated hash of it — not the full value.
The class lives in the native package (p004native in jadx). For Frida, use the real name from the APK metadata. Two solid interception points:
Java.perform(() => {
// option 1: hook the Java callback that receives the attestation
// real class name: com.courierdrop.app.native.NativeAttestation
// (jadx shows it under p004native — use the real name for Java.use())
const NA = Java.use("com.courierdrop.app.native.NativeAttestation");
NA.onScanSuccess.implementation = function(attestation) {
console.log("[FLAG2] onScanSuccess:", attestation);
return this.onScanSuccess(attestation);
};
// option 2: hook the repository save
const RR = Java.use("com.courierdrop.app.data.RouteRepository");
RR.saveAttestation.implementation = function(att) {
console.log("[FLAG2] saveAttestation:", att);
return this.saveAttestation(att);
};
});
Alternative at the JNI boundary: hook NewStringUTF — every string the native side creates passes through it, so you'll see the attestation the moment it's constructed, before it even reaches the Java callback.
Stage 3 — anti-debug bypass + encrypted locker (FLAG 3)
The "Secure Notes / Locker" section runs Guard.check() before showing anything real. The guard checks three things:
public static final boolean check() {
return !isDebuggerConnected() // Debug.isDebuggerConnected()
&& !hasTracerPid() // reads TracerPid from /proc/self/status
&& !hasFridaMaps(); // scans /proc/self/maps for "frida-agent"
}
With Frida attached, hasFridaMaps() fires and the guard returns false, so you get plausible-looking placeholder notes instead of the real content. The simplest bypass — hook Guard.check() to always return true:
Java.perform(() => {
const Guard = Java.use("com.courierdrop.app.crypto.Guard");
Guard.check.implementation = function() {
console.log("[Stage3] Guard bypassed");
return true;
};
});
Once the guard passes, SecureLockerActivity reads the Stage 2 attestation from RouteRepository, derives key3 = SHA-256(FLAG2 + "|locker|salt|2026"), decrypts blob3, and FLAG 3 appears both in the Frida output and as a visible note in the Locker UI.
Run Stages 2 and 3 in the same Frida session — the locker reads RouteRepository.getAttestation(), which is only populated after Stage 2. If you attach a fresh session for Stage 3, that in-memory value is gone and blob3 decryption fails silently with sanitized notes.
frida -U -f com.courierdrop.app \
-l stage2_native.js \
-l stage3_guard_bypass.js
- Navigate to a stop → tap Simulate Scan → FLAG 2 appears in output
- Navigate to Locker tab → FLAG 3 appears in output and in the UI
Patching the smali (flip the branch in SecureLockerActivity that gates on the guard result, rebuild and re-sign with apktool) works too if you prefer a static approach — the Frida route is faster in practice.
Stage 4 — HMAC proof + local verifier (FLAG 4)
The final screen is "Complete Delivery". The app constructs a POD (proof-of-delivery) JSON payload, signs it with HMAC, and sends it to a local HTTP verifier that spins up on a random 127.0.0.1 port. The port is visible in Diagnostics after Stage 1.
The signing scheme, reversed from DeliveryActivity.java:
# K_pod = SHA-256(FLAG3 + "|pod|hmac|2026")
K_pod = hashlib.sha256((flag3 + "|pod|hmac|2026").encode()).digest()
nonce = os.urandom(16)
canonical = nonce + hashlib.sha256(payload_json.encode()).digest()
proof = hmac.new(K_pod, canonical, hashlib.sha256).digest()
# POST http://127.0.0.1:PORT/verify
# X-Nonce: base64(nonce)
# X-Proof: base64(proof)
# Content-Type: application/json
The local verifier reconstructs the canonical payload, recomputes the HMAC, and — if it matches — returns a JSON receipt with audit_token. That's FLAG 4. The audit_token in the server is stored XOR-encrypted with a key derived from FLAG 3, so there's no static path to it without completing Stage 3 first.
The verifier runs inside the emulator. Before sending anything, forward the port to the host:
adb forward tcp:PORT tcp:PORT
Then build and send the proof — either with the solve script below, or by tapping "Complete Delivery" in the app and hooking showSuccess() with Frida to read the full response JSON directly.
Stage 4 solve script
Reproduces the exact HMAC construction from DeliveryActivity.java and sends the request directly to the local verifier. Get the port from Diagnostics and run with the three flags from the previous stages.
#!/usr/bin/env python3
"""
CourierDrop — Stage 4 Solver
Usage:
adb forward tcp:PORT tcp:PORT
python3 solve_stage4.py --port PORT --flag1 FLAG1 --flag2 FLAG2 --flag3 FLAG3
"""
import argparse, base64, hashlib, hmac as hmac_module, json, os, socket
def sha256(data: bytes) -> bytes:
return hashlib.sha256(data).digest()
def derive_key(secret: str, salt: str) -> bytes:
# matches BlobDecryptor.deriveKey(): SHA-256(secret + "|" + salt)
return hashlib.sha256(secret.encode() + b"|" + salt.encode()).digest()
def build_request(flag1, flag2, flag3):
payload = json.dumps({
"session_id": flag1,
"driver_id": "DRV-0047",
"recipient": "CTFSolver",
"delivery_method": "FRONT_DESK",
"attestation": flag2,
"timestamp": 1700000000000
}, separators=(',', ':'))
nonce = os.urandom(16)
k_pod = derive_key(flag3, "pod|hmac|2026")
proof = hmac_module.new(k_pod, nonce + sha256(payload.encode()), hashlib.sha256).digest()
return payload, base64.b64encode(nonce).decode(), base64.b64encode(proof).decode()
def post_verify(port, payload, nonce_b64, proof_b64):
body = payload.encode()
req = (
f"POST /verify HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n"
f"Content-Type: application/json\r\nContent-Length: {len(body)}\r\n"
f"X-Nonce: {nonce_b64}\r\nX-Proof: {proof_b64}\r\nConnection: close\r\n\r\n"
).encode() + body
with socket.create_connection(("127.0.0.1", port), timeout=5) as s:
s.sendall(req)
resp = b""
while chunk := s.recv(4096):
resp += chunk
sep = b"\r\n\r\n" if b"\r\n\r\n" in resp else b"\n\n"
body = resp.partition(sep)[2]
return json.loads(body.decode().strip())
def main():
p = argparse.ArgumentParser()
p.add_argument("--port", required=True, type=int)
p.add_argument("--flag1", required=True)
p.add_argument("--flag2", required=True)
p.add_argument("--flag3", required=True)
args = p.parse_args()
payload, nonce_b64, proof_b64 = build_request(args.flag1, args.flag2, args.flag3)
result = post_verify(args.port, payload, nonce_b64, proof_b64)
print("FLAG4:", result.get("audit_token", result))
if __name__ == "__main__":
main()