Cheese

Reproducing a lockdown browser's integrity headers from its config file alone, without ever installing it.

2026.09.17 NNS CTF 2026 235 pts Misc
FLAG NNS{5aY_cHe353_4Nd_sm113_F0R_7H3_l0CkdoWN_bRoWs3R_anD_h0P3_tHat_yoU_6e7_7he_CorReC7_4n5w3R_50meHow}

Challenge

The instance serves a small page:

This exam runs in Safe Exam Browser only. Launch exam -> sebs:///exam.seb Configuration password: J9clyeECFHZb

Safe Exam Browser (SEB) is a real lockdown/kiosk browser used for online exams. GET /exam returns 403 {“detail”:“Safe Exam Browser required”} unless the request looks like it came from a real, correctly configured SEB client. Rather than installing SEB (a kiosk app that takes over the screen - exactly what the challenge note warns against) and going through a real exam session, the goal is to “cheese” it: replicate SEB’s client-side integrity headers by hand from the config file alone.

Step 1: decrypt exam.seb

exam.seb is: outer gzip -> “pswd” + RNCryptor-like encrypted blob (password-based) -> AES-256-CBC plaintext, itself gzip-compressed -> Apple XML plist (the actual SEB settings)

Cloned the open-source SEB Windows client (github.com/SafeExamBrowser/seb-win-refactoring) to get the exact byte layout and crypto parameters from SafeExamBrowser.Configuration/Cryptography/PasswordEncryption.cs:

version(1) | options(1) | encryptionSalt(8) | authSalt(8) | IV(16) | ciphertext | HMAC-SHA256(32)

encryptionKey = PBKDF2-HMAC-SHA1(password, encryptionSalt, 10000 iters, 32 bytes) authKey = PBKDF2-HMAC-SHA1(password, authSalt, 10000 iters, 32 bytes) HMAC = HMAC-SHA256(authKey, header||ciphertext) (must match trailing 32 bytes) plaintext = AES-256-CBC-decrypt(ciphertext, encryptionKey, IV), PKCS7-unpadded

The key gotcha: .NET’s Rfc2898DeriveBytes(password, salt, iterations) 3-argument constructor defaults to HMAC-SHA1 as its PRF (not SHA-256 as generic RNCryptor docs suggest) - using SHA-256 for the KDF made the HMAC check fail every time. Switching to SHA1 for both PBKDF2 calls made the computed HMAC match immediately, and AES-CBC decryption then yields a gzip-compressed plist that decompresses to the full SEB configuration (~95 settings), including startURL = https://.../exam and sendBrowserExamKey = true.

Step 2: compute the SEB “Config Key” header

SEB authenticates requests to the exam URL with a header computed purely from the (decrypted) configuration file and the request URL - no secret beyond the config itself is required:

ConfigurationKey = SHA256( canonical_json(full_raw_settings_dict) ) -> hex ConfigKeyHash = SHA256( url_without_fragment + ConfigurationKey ) -> hex header: X-SafeExamBrowser-ConfigKeyHash:

The “canonical JSON” serializer is intentionally idiosyncratic (from SafeExamBrowser.Configuration/ConfigurationData/Json.cs), not standard JSON:

  • dict keys sorted by .NET StringComparer.InvariantCulture (approximated in Python with sorted(keys, key=lambda s: (s.lower(), s)) - critical, since a naive ordinal/codepoint sort puts all-uppercase keys like “URLFilterEnable” before any lowercase key, which is wrong; culture-aware sort interleaves by letter, e.g. “URLFilterEnable” sorts next to “urlFilterRegex”, not at the very top)
  • the “originatorVersion” key is dropped
  • empty <dict> values are dropped (empty arrays are kept)
  • no whitespace, no character escaping at all (not valid JSON if a string contains a quote - doesn’t matter here)
  • bool -> “true”/“false” lowercase, byte data -> base64, no trailing comma

Re-implemented this exactly in Python using plistlib (which preserves int/real/date/data distinctions from the plist) to parse exam.plist, then serialized with the matching rules and hashed with SHA-256 twice as above.

Step 3: request the flag

Sent a plain curl/HTTP GET to /exam with just the computed X-SafeExamBrowser-ConfigKeyHash header (no SEB installation, no X-SafeExamBrowser-RequestHash/Browser-Exam-Key needed - the server only checks the Config Key hash) and got back HTML containing the flag directly in an inline <script> block (a client-side JS string comparison “exam question” that never actually needed answering - the flag was already sitting in the page source once past the SEB gate).

Flag

NNS{5aY_cHe353_4Nd_sm113_F0R_7H3_l0CkdoWN_bRoWs3R_anD_h0P3_tHat_yoU_6e7_7he_CorReC7_4n5w3R_50meHow}

#safe-exam-browser#crypto#reversing