Description
What kind of information can an Add/On reach? Is it possible to exfiltrate them without you noticing? Download the browser extension suspicious.zip (password: picoctf) and inspect it to uncover the hidden flag.
unzip -P picoctf suspicious.zipunzip *.xpi -d suspicious/ls -R suspicious/Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Extract the extension
ObservationThe download is a password-protected ZIP holding a browser extension, so extraction comes first. With the tree on disk you can read the manifest, follow script imports, and grep everything at once.Extract first. The password-protected archive does not hold the extension tree directly: it holds one file, 56102ec0438646c68605-1.0.xpi. An .xpi is a Firefox extension, which is itself an ordinary ZIP, so unzip it a second time to get manifest.json, background/main.js, assets/script.js, popup.html and the META-INF signature files.bash# First layer - password protected, contains a single .xpi:bashunzip -P picoctf suspicious.zipbash# Second layer - the .xpi is a plain ZIP:bashunzip 56102ec0438646c68605-1.0.xpi -d suspicious/bashls -R suspicious/Expected output
suspicious/manifest.json suspicious/popup.html suspicious/background/main.js suspicious/assets/script.js suspicious/assets/styles.css suspicious/META-INF/manifest.mf
What didn't work first
Tried: Run
unzip suspicious.zipwithout the -P flag to extract without a password.unzip prompts for a password and fails if you guess wrong, leaving no files behind. The archive uses the traditional ZipCrypto password scheme with the password picoctf, so pass it with -P or type it at the prompt.
Tried: Try
file suspicious.zipand thenbinwalk suspicious.ziplooking for hidden nested archives before extracting.binwalk reports the outer ZIP and the entries inside it, but those entries are ordinary compressed members, not standalone archives. Carving at its offsets gives corrupted fragments rather than the extension tree. Extract properly and keep the paths and directory structure.
Learn more
Extension formats are all ZIP archives under the hood. Chrome
.crx= ZIP + a small CRX3 signature header prepended; Firefox.xpi= ZIP, no header; this challenge ships a plain.zipalready. If you ever need to peel the CRX header off, the data starts at the offset where the bytesPKfirst appear (standard ZIP magic).Extract vs grep-the-archive.
unzip -p archive.zip path/to/filestreams a single file to stdout without writing anything to disk - good for one-shot extraction during triage. For real analysis (manifest review, cross-file string search, deobfuscation), extract everything: tools and editors work better against a real directory tree.Every extension contains:
manifest.json(configuration + permissions), background scripts / service workers, content scripts (injected into web pages), and optional popup HTML/CSS/JS. Malicious extensions slip past the Chrome Web Store / Firefox AMO review by being benign on submission and pulling malicious payloads later, or by hiding behavior behind obfuscation.Step 2Inspect manifest.json
ObservationEvery extension carries a manifest.json declaring its permissions and entry points. Read it first to see the attack surface and which scripts run in which context.Read the manifest. It tells you exactly what the extension can do, which JS files run in which context, and where the flag is most likely embedded.pythonpython3 -m json.tool suspicious/manifest.jsonbashgrep -iE 'picoCTF|flag|secret' suspicious/manifest.jsonWhat didn't work first
Tried: Install the extension directly in Chrome (via chrome://extensions/ in Developer Mode) and look for the flag in the DevTools console or network tab at runtime.
Installing an unknown malicious extension in your own browser hands your session cookies and browsing history to whatever exfiltration logic it carries, and even sandboxed it may phone home. Static inspection is safer and enough here: the flag is in the source, not produced at runtime.
Tried: Search only manifest.json for the flag string, since the challenge says to 'inspect the extension'.
The manifest is configuration: permissions and entry points, no code and no embedded data. The grep finds nothing. Follow its background and content script entries to the JavaScript files themselves.
Learn more
What each suspicious permission actually grants:
"<all_urls>"- the extension can inject content scripts into every website you visit and make cross-origin HTTP requests to any server. Effectively converts the extension into a universal man-in-the-browser."webRequestBlocking"- lets the extension synchronously intercept, modify, redirect, or cancel any outgoing HTTP request before it leaves the browser. Adblockers use this; so do credential-stealing exfiltration scripts."cookies"- read and write cookies for any domain (paired with host permissions). Game over for session cookies on every site you're logged into."nativeMessaging"- communicate with a co-installed native binary outside the browser sandbox. Real malware uses this to hand control to a persistence-installing helper."tabs"- read URLs and titles of every open tab."storage"- on its own, benign. Combined with the above, it's where exfiltrated data is staged.
A legitimate extension needs a small subset of these. Asking for several of them at once - particularly
<all_urls>+cookies+webRequestBlocking- is the classic credential-stealer permission stack.Step 3Search all JS files for the flag and encoded data
ObservationThe manifest holds no flag, so it is inside the JavaScript it references. Sweep those files with grep for the raw prefix and for the usual obfuscation signatures: base64 blobs, charcode arrays, hex escapes, exfiltration sinks.Grep raw first (cheap, often just works), then sweep for each obfuscation form by signature. Also flag any exfiltration call (fetch/XHR/sendBeacon) - those point at where the encoded payload is built.bash# Raw plaintext first - sometimes the flag is just sitting there:bashgrep -rn 'picoCTF' suspicious/bash# Base64 candidates: 20+ alphabet chars, optional = padding:bashgrep -rnE '[A-Za-z0-9+/]{20,}={0,2}' suspicious/ --include='*.js'bash# String.fromCharCode arrays: bracket + comma-separated char codes:bashgrep -rnE '\[[0-9]{2,3}(,[0-9]{2,3})+\]' suspicious/ --include='*.js'bash# Hex escape sequences inside JS string literals:bashgrep -rnE '\\x[0-9a-f]{2}' suspicious/ --include='*.js'bash# Exfiltration sinks - the encoded payload is built nearby:bashgrep -rnE '(fetch|XMLHttpRequest|sendBeacon|navigator\.sendBeacon)' suspicious/ --include='*.js'Expected output
background/main.js:6: const key="cGljb0NURnt5b3UncmUgb24gdGhlIHJpZ2h0IHRyYX0="
What didn't work first
Tried: Run
strings suspicious.zipon the zip archive itself to find the flag without extracting.strings over a compressed ZIP is mostly deflate garbage, since compressed content is not printable ASCII, and even stored entries appear without file boundaries, so a hit tells you nothing about which file it came from. Extract first, then grep the real files for line numbers and context.
Tried: Limit the base64 grep to the manifest.json and popup HTML files, skipping background and content scripts.
The flag lives in the background service worker or a content script, where the logic is, not in the manifest or the popup HTML. Restrict the grep to the wrong file types and it never reads the file that has it.
Learn more
Concrete signatures by obfuscation type:
- Base64:
[A-Za-z0-9+/]{20,}={0,2}. Real base64 strings of meaningful length plus optional=padding.cGljb0NURntoZWxsb30=decodes topicoCTF{hello}. - Charcode arrays:
\[[0-9]{2,3}(,[0-9]{2,3})+\]. Matches[112,105,99,111,67,84,70,123], which decodes topicoCTF{. - Hex escapes:
\\x[0-9a-f]{2}inside JS string literals."\x70\x69\x63\x6f"=pico. - Unicode escapes:
\\u[0-9a-f]{4}. Same idea, longer form:"\u0070\u0069\u0063\u006f"=pico.
Decoder strategy: try cheapest first. Pipe each candidate through base64 decode; print outputs that contain printable ASCII. Fall back to charcode-array decode (Python:
chr()mapped over the list). Then hex/unicode escapes (codecs.decode(s, 'unicode_escape')). Print all candidate decodes; eyeball forpicoCTF{or anything that reads as English.Exfiltration sinks are useful as triangulation. The flag is rarely a static string; it's usually built into the data the extension would send out. Find the
fetch()/XMLHttpRequest/navigator.sendBeacon()call, walk back through the variables that build its body argument, and the encoded form is right there. See the CTF Encodings guide for the full decoder ladder, and the Web Challenges guide for adjacent extension-and-DOM patterns.- Base64:
Step 4Recognise the decoy and decrypt the Fernet token
ObservationThe base64 constant decodes to something that looks like the flag but is not: it endsright tra}mid-word, and the comment above it says the value is a 32-byte url-safe key. A truncated-looking flag that is exactly 32 bytes long is not a flag, it is a key, and the only other opaque blob in the file is the thing it unlocks.Decode the constant and you getpicoCTF{you're on the right tra}, exactly 32 bytes. It is a partial flag used as bait and, simultaneously, a real Fernet key: the comment on line 1 says "Secret key must be 32 url-safe base64-encoded bytes". The variable next to it, webhookUrl, is not a URL at all but a Fernet token (every Fernet token startsgAAAAA, the base64 of version byte 0x80 followed by a timestamp). Decrypt the token with that key and the real flag falls out.pythonpython3 -c "import base64;print(base64.b64decode('cGljb0NURnt5b3UncmUgb24gdGhlIHJpZ2h0IHRyYX0='))"bashpip install cryptographypythonpython3 -c "from cryptography.fernet import Fernet; k=b'cGljb0NURnt5b3UncmUgb24gdGhlIHJpZ2h0IHRyYX0='; t=b'gAAAAABmfRjwFKUB-X3GBBqaN1tZYcPg5oLJVJ5XQHFogEgcRSxSis1e4qwicAKohmjqaD-QG8DIN5ie3uijCVAe3xiYmoEHlxATWUP3DC97R00Cgkw4f3HZKsP5xHewOqVPH8ap9FbE'; print(Fernet(k).decrypt(t).decode())"Expected output
b"picoCTF{you're on the right tra}" picoCTF{Us3_4dd/0ns_v3ry_...}What didn't work first
Tried: Submit the decoded base64 string picoCTF{you're on the right tra} as the flag.
That is the decoy, and it says so: the sentence is cut off mid-word at 'tra'. It was padded to exactly 32 bytes because Fernet requires a 32-byte key, not because it is a complete flag. Its only job is to be the key for the token in the next line.
Tried: Treat webhookUrl as a real URL and try to fetch it, or search it for an embedded hostname.
It is a Fernet token, not a URL: base64url of version 0x80, an 8-byte timestamp, a 16-byte IV, AES-128-CBC ciphertext and a 32-byte HMAC-SHA256 tag. There is no readable host in it, and nothing to request. The
gAAAAAprefix is the giveaway, since 0x80 0x00 0x00 0x00 0x00 always encodes to those characters.Tried: Brute-force a single-byte XOR key against the gAAAAA blob after base64-decoding it.
The payload is AES-128-CBC, so no XOR key of any length recovers it. The construction is authenticated too: Fernet verifies the HMAC before decrypting, so a wrong key raises InvalidToken rather than returning garbage you could score.
Learn more
Fernet is the recipe from Python's
cryptographylibrary for authenticated symmetric encryption. A key is 32 random bytes in url-safe base64; internally it is split in half, the first 16 bytes signing with HMAC-SHA256 and the last 16 encrypting with AES-128-CBC. A token is0x80+ 8-byte big-endian timestamp + 16-byte IV + ciphertext + 32-byte HMAC, all base64url-encoded, which is why every Fernet token beginsgAAAAA.The trap here is that Fernet accepts any 32 url-safe base64 bytes as a key, including ASCII text a human chose. That is what lets the author use a sentence as the key, and it is the same property that makes hardcoded Fernet keys such a common real-world finding: the key is only as secret as the source file it sits in, and here the source ships to every user who installs the extension.
Note the ordering the author left behind. The comment says "I must find a solution to remove the key from here, for now I'll leave it there because I need it to encrypt the webhook." That is the whole vulnerability class in one sentence: an extension cannot hold a secret, because the client has the code. Anything the extension can decrypt, so can whoever unzips it.
Interactive tools
- Strings ExtractorPull printable text from any binary, library, or image. ASCII and UTF-16 detection, configurable minimum length, flag-like highlight, no command line needed.
- Base64 & Base32 DecoderDecode Base64 and Base32 strings with auto-detection. Multi-layer mode unwraps nested encodings automatically.
- Recipe ChainStack decoders into a pipeline: Base64, hex, ROT, XOR, Morse, URL, Atbash, Vigenère, and more. Magic mode auto-discovers the chain. Bookmark the URL to save it.
Flag
Reveal flag
picoCTF{Us3_4dd/0ns_v3ry_...}
The archive is a ZIP inside a ZIP: unzip -P picoctf, then unzip the .xpi. background/main.js holds a base64 constant that decodes to picoCTF{you're on the right tra}, a 32-byte decoy that is really the Fernet key for the gAAAAA token stored in webhookUrl. Decrypt that token to get the flag, which is shown abbreviated on this page.