sploit for arena-battle
This commit is contained in:
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user