Skip to main content

The Add/On Trap picoCTF 2026 Solution

Dig into a browser extension's source to find a flag concealed through obfuscation and encoding tricks.

Published: March 20, 2026Updated: September 22, 2026

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.

Download suspicious.zip and extract it using the password 'picoctf'.
The archive holds a single .xpi file, which is itself a ZIP. Unzip that too.
Inspect the extracted extension files for hidden data.
bash
unzip -P picoctf suspicious.zip
bash
unzip *.xpi -d suspicious/
bash
ls -R suspicious/

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Extract the extension
    Observation
    The 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:
    bash
    unzip -P picoctf suspicious.zip
    bash
    # Second layer - the .xpi is a plain ZIP:
    bash
    unzip 56102ec0438646c68605-1.0.xpi -d suspicious/
    bash
    ls -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.zip without 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.zip and then binwalk suspicious.zip looking 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 .zip already. If you ever need to peel the CRX header off, the data starts at the offset where the bytes PK first appear (standard ZIP magic).

    Extract vs grep-the-archive. unzip -p archive.zip path/to/file streams 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.

  2. Step 2Inspect manifest.json
    Observation
    Every 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.
    python
    python3 -m json.tool suspicious/manifest.json
    bash
    grep -iE 'picoCTF|flag|secret' suspicious/manifest.json
    What 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.

  3. Step 3Search all JS files for the flag and encoded data
    Observation
    The 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:
    bash
    grep -rn 'picoCTF' suspicious/
    bash
    # Base64 candidates: 20+ alphabet chars, optional = padding:
    bash
    grep -rnE '[A-Za-z0-9+/]{20,}={0,2}' suspicious/ --include='*.js'
    bash
    # String.fromCharCode arrays: bracket + comma-separated char codes:
    bash
    grep -rnE '\[[0-9]{2,3}(,[0-9]{2,3})+\]' suspicious/ --include='*.js'
    bash
    # Hex escape sequences inside JS string literals:
    bash
    grep -rnE '\\x[0-9a-f]{2}' suspicious/ --include='*.js'
    bash
    # Exfiltration sinks - the encoded payload is built nearby:
    bash
    grep -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.zip on 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 to picoCTF{hello}.
    • Charcode arrays: \[[0-9]{2,3}(,[0-9]{2,3})+\]. Matches [112,105,99,111,67,84,70,123], which decodes to picoCTF{.
    • 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 for picoCTF{ 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.

  4. Step 4Recognise the decoy and decrypt the Fernet token
    Observation
    The base64 constant decodes to something that looks like the flag but is not: it ends right 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 get picoCTF{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 starts gAAAAA, the base64 of version byte 0x80 followed by a timestamp). Decrypt the token with that key and the real flag falls out.
    python
    python3 -c "import base64;print(base64.b64decode('cGljb0NURnt5b3UncmUgb24gdGhlIHJpZ2h0IHRyYX0='))"
    bash
    pip install cryptography
    python
    python3 -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 gAAAAA prefix 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 cryptography library 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 is 0x80 + 8-byte big-endian timestamp + 16-byte IV + ciphertext + 32-byte HMAC, all base64url-encoded, which is why every Fernet token begins gAAAAA.

    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.

Key takeaway

A browser extension runs with elevated privileges: it intercepts requests, reads cookies, and injects scripts into every page you visit. The permissions in its manifest are the attack surface, and access to all URLs plus cookies plus request blocking is everything needed to lift session tokens from any site silently. It also cannot keep a secret: any key the extension uses to protect its own traffic ships to every user, so a hardcoded Fernet or AES key turns the encrypted payload into plaintext for anyone who unzips the package. Treat every constant next to an opaque blob as the key for it.

Related reading

Useful tools for Reverse Engineering

Where to go next