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
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
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
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
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.
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.
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.
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:
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.
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.
Đ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:
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
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.
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:
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:
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
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.
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.