sploit for arena-battle
build-and-push / detect (push) Successful in 10s
build-and-push / build (${{ fromJSON(needs.detect.outputs.services) }}) (push) Successful in 51s

This commit is contained in:
2026-08-26 11:29:05 +03:00
parent 593500424a
commit 5d765a09db
1436 changed files with 539800 additions and 14 deletions
+94
View File
@@ -0,0 +1,94 @@
# arena-battle — findings & patches
TCP service, port **1337** (container 31337). Line protocol, one command per packet.
Flag store: **fighter notes** (`NOTE_CREATE` / `NOTE_GET`).
## 1. Predictable auth tokens — CRITICAL, flag leak
`create_fighter()`:
```cpp
f.auth_token = "token_" + std::to_string(f.id) + "_" + std::to_string(rand());
```
`srand()` is never called, so glibc `rand()` emits its default seed-1 sequence
(1804289383, 846930886, 1681692777, …). Fighter ids are sequential and `rand()`
is called exactly once per fighter, so **the Nth fighter's token is fully
computable offline**.
`NOTE_GET` authenticates on the token alone — no session, no ownership check —
so any token dumps that fighter's notes:
```
REGISTER x warrior -> tells us the current fighter id (= fighter count)
NOTE_GET token_<id>_<rand[id]> -> note list
NOTE_GET token_<id>_<rand[id]> <n> -> title + content (flag)
```
Verified: `sploits/arena_battle.py` pulls 15/15 planted flags in 0.23 s against
a box with 1600 fighters.
**Patch:** tokens now come from `/dev/urandom` (64-bit, `gen_auth_token()`),
keeping the exact `token_<id>_<digits>` shape so clients see no change.
A CSPRNG is required here, not `mt19937` — an attacker who registers a few
hundred fighters can recover a Mersenne Twister state from its outputs.
Also added: a connection is dropped after 16 invalid tokens (`MAX_BAD_TOKENS`),
so token guessing cannot be scaled up.
## 2. `BUY` integer overflow — free gold
`int total_price = item.price * quantity;` overflows, and the code then *rewards*
it: `f.gold += abs(total_price)` returning `"OK: Overflow exploited!"`.
**Patch:** 64-bit arithmetic plus an explicit cap; the free-gold branch is gone.
## 3. No locking on shared state — crash risk (SLA)
`fighters`, `battles`, `client_fighter` and `fighter_battle` were mutated from
every client thread with **no mutex at all**`state_mutex` was only ever taken
by `cleanup_thread`. Concurrent `std::map` inserts are undefined behaviour, and
`cleanup_thread` erasing a fighter while a handler holds a `Fighter&` to it is a
use-after-free.
**Patch:** every command and the per-connection teardown now run under
`state_mutex`. (I could not force a crash in testing — this is hardening against
a real race, not a demonstrated exploit.)
## 4. `cleanup_thread` closed file descriptors owned by live threads
It called `close(cf->first)` on a socket whose handler thread was still in
`read()`. Once closed, the fd number is recycled onto the *next* accepted
connection, and the stale handler then reads/writes another client's socket.
**Patch:** the mapping is erased, the fd is left to its owning thread.
## 5. `operator[]` on missing fighters
`cmd_attack` / `cmd_status` did `fighters[defender_id]`, inserting a blank
fighter when the id was gone (e.g. cleaned up mid-battle).
**Patch:** existence checked first.
## 6. `listen(server_fd, 10)` — trivial connection-storm DoS
A backlog of 10 lets a burst of connections lock the checker out; this actually
happened during load testing. **Patch:** backlog 256.
## 7. Docker
- `docker-compose.yml` had no `image:` line — the CI job does
`docker compose config --images | grep "^git.itqdev.xyz/4x10m/"` and
`exit 1`s when empty, so **every push failed**. Added.
- Healthcheck used `nc`, which is not installed in the image, so the container
was permanently `unhealthy`. Switched to a bash `/dev/tcp` probe.
## Not fixed — watch these
- **Flags are written to `logs/server.log`**: `LOG(client_str + " " + line)`
logs the full command line, so every `NOTE_CREATE <title> <flag>` lands on
disk in cleartext. Harmless on its own, but it turns any file-read or RCE
anywhere on the box into a full flag dump. Consider redacting.
- `split(line, ' ')` means a note title/content can never contain a space, and
two commands in one TCP packet are parsed as one. Left as-is: the checker
depends on this behaviour.
- Other teams may "fix" the token bug with `srand(time(NULL))`, which is still
breakable (brute-force the seed, or recover the generator state from ~62
tokens we register ourselves). The sploit prints a warning on token mismatch;
building that fallback is the next step if flags dry up.
+3 -1
View File
@@ -3,11 +3,13 @@ version: '3.8'
services:
arena-battle:
build: .
image: git.itqdev.xyz/4x10m/arena-battle:latest
ports:
- "1337:31337"
restart: unless-stopped
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "31337"]
# was `nc -z`, but netcat is not installed in the image -> always unhealthy
test: ["CMD-SHELL", "bash -c '</dev/tcp/127.0.0.1/31337' || exit 1"]
interval: 10s
timeout: 5s
retries: 3
+65 -13
View File
@@ -21,6 +21,33 @@
#include <signal.h>
#include <arpa/inet.h>
#include <sys/time.h>
#include <cstdint>
#include <random>
// Auth tokens must be unguessable: NOTE_GET authenticates on the token alone.
// Keeps the original "token_<id>_<digits>" shape so clients see no change.
std::ifstream urandom_source("/dev/urandom", std::ios::binary);
std::mutex urandom_mutex;
std::string gen_auth_token(int id) {
uint64_t v = 0;
{
std::lock_guard<std::mutex> lock(urandom_mutex);
if (urandom_source.is_open()) {
urandom_source.read(reinterpret_cast<char*>(&v), sizeof(v));
if (!urandom_source) {
urandom_source.clear();
v = 0;
}
}
}
if (v == 0) {
std::random_device rd;
v = (static_cast<uint64_t>(rd()) << 32) ^ static_cast<uint64_t>(rd());
}
v |= (1ULL << 63);
return "token_" + std::to_string(id) + "_" + std::to_string(v);
}
struct Item {
int id;
@@ -151,7 +178,7 @@ int create_fighter(const std::string& name, const std::string& fighter_class) {
f.last_activity = now();
f.active = true;
f.auth_token = "token_" + std::to_string(f.id) + "_" + std::to_string(rand());
f.auth_token = gen_auth_token(f.id);
if (fighter_class == "warrior") {
f.strength = 15;
@@ -227,20 +254,18 @@ std::string cmd_buy(int client_fd, const std::vector<std::string>& args) {
Fighter& f = fighters[fid];
Item& item = items[item_id];
int total_price = item.price * quantity;
int64_t total_price = static_cast<int64_t>(item.price) * static_cast<int64_t>(quantity);
if (total_price < 0) {
f.gold += abs(total_price);
f.last_activity = now();
return "OK: Overflow exploited! Gold:" + std::to_string(f.gold);
if (total_price > 1000000000LL) {
return "ERROR: Invalid quantity";
}
if (f.gold < total_price) {
return "ERROR: Not enough gold. Need:" + std::to_string(total_price) +
" Have:" + std::to_string(f.gold);
}
f.gold -= total_price;
f.gold -= static_cast<int>(total_price);
for (int i = 0; i < quantity; i++) {
f.inventory.push_back(item_id);
}
@@ -389,6 +414,7 @@ std::string cmd_attack(int client_fd, const std::vector<std::string>&) {
Fighter& attacker = fighters[fid];
int defender_id = (b.fighter1_id == fid) ? b.fighter2_id : b.fighter1_id;
if (!fighters.count(defender_id)) return "ERROR: Opponent not found";
Fighter& defender = fighters[defender_id];
// Calculate damage
@@ -487,6 +513,7 @@ std::string cmd_status(int client_fd) {
Battle& b = battles[bid];
Fighter& f = fighters[fid];
int opponent_id = (b.fighter1_id == fid) ? b.fighter2_id : b.fighter1_id;
if (!fighters.count(opponent_id)) return "ERROR: Opponent not found";
Fighter& opponent = fighters[opponent_id];
std::stringstream ss;
@@ -561,8 +588,11 @@ std::string cmd_note_get(const std::vector<std::string>& args) {
return "ERROR: Note not found";
}
static const int MAX_BAD_TOKENS = 16;
void handle_client(int client_fd) {
char buffer[1024];
int bad_tokens = 0;
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
@@ -593,6 +623,9 @@ void handle_client(int client_fd) {
std::string response;
{
std::lock_guard<std::mutex> lock(state_mutex);
if (cmd == "REGISTER") {
response = cmd_register(client_fd, args);
} else if (cmd == "FIGHTER") {
@@ -615,20 +648,37 @@ void handle_client(int client_fd) {
response = cmd_note_create(client_fd, args);
} else if (cmd == "NOTE_GET") {
response = cmd_note_get(args);
if (response.rfind("ERROR: Invalid auth token", 0) == 0) {
bad_tokens++;
}
} else if (cmd == "LOGOUT") {
response = cmd_logout(client_fd);
} else if (cmd == "QUIT" || cmd == "EXIT") {
response = "BYE";
write(client_fd, response.c_str(), response.length());
break;
} else {
response = "ERROR: Unknown command: " + cmd;
}
}
// QUIT/EXIT keeps its original reply: "BYE" with no trailing newline.
if (cmd == "QUIT" || cmd == "EXIT") {
write(client_fd, response.c_str(), response.length());
break;
}
response += "\n";
write(client_fd, response.c_str(), response.length());
// Throttle token guessing: a legitimate client never needs this many.
if (bad_tokens >= MAX_BAD_TOKENS) {
LOG(client_str + " disconnected: too many invalid auth tokens");
break;
}
}
std::lock_guard<std::mutex> lock(state_mutex);
// Cleanup
if (client_fighter.count(client_fd)) {
int fid = client_fighter[client_fd];
@@ -667,7 +717,8 @@ void cleanup_thread() {
for (auto cf = client_fighter.begin(); cf != client_fighter.end(); ) {
if (cf->second == fid) {
close(cf->first);
// Do not close(): the handler thread still owns this fd
// and the number would be recycled onto another client.
cf = client_fighter.erase(cf);
} else {
++cf;
@@ -733,7 +784,8 @@ int main(int argc, char* argv[]) {
return 1;
}
if (listen(server_fd, 10) < 0) {
// Backlog of 10 let a burst of connections lock the checker out.
if (listen(server_fd, 256) < 0) {
return 1;
}