Cũng đã 1 khoảng thời gian sau lần cuối mình viết write up ctf challenge, phải nói rằng là khả năng chơi ctf của mình đang kém và cần phải rèn dũa thêm, kết quả của cuộc thi lần này đã minh chứng cho điều đó.
Mình tham gia KCSC CTF với team 4 người chơi web và sau 8 tiếng bọn mình solve được 1 bài trên tổng số 6 mà bài web đó không phải do cả team cùng làm mà mang tính cá nhân rõ rệt. Bọn mình đã không trao đổi và cùng nhau giải cái challenge mà lại đi chơi kiểu mỗi người một chall, dẫn đến 4 anh em stuck ở 4 nơi mà chả biết phải support nhau như thế nào, đây là bài học quý giá mà mình cần phải rút kinh nghiệm trong khoản giao tiếp với đồng đội ở các cuộc thi tới. Các web challenge năm nay đối với mình đều rất mới, độc và lạ. Nó toàn là những kiến thức mình không biết hoặc là rất yếu nên sau cuộc thi mình đã học được rất nhiều qua những anh em làm được và chỉ cho mình. Thôi không lan man nữa, mình sẽ bắt đầu nói về quá trình tìm tòi và giải quyết các web challenge của giải này.
Đây là challenge mình dành nhiều thời gian nhất để tập trung giải quyết, tuy nhiên mình vẫn không thể giải được và nhận ra mình đã hiểu sai bản chất sau khi biết được hướng solve.
Challenge bao gồm web server flask và nginx đứng ra làm proxy, challenge cũng có source nên mình sẽ đi vào phân tích luôn
Dựa vào file docker chỉ có quyền đọc và flag không thêm ký tự bất kỳ, mình nghĩ có thể solve challenge bằng lỗi đọc file local mà có thể không phải RCE
Chức năng của webserver khá đơn giản, chỉ có một route duy nhất /
có khả năng đọc file nằm trong thư mục static thông qua tham số file
có trong url:
Như mình nói ở trên, giá trị của biến file
được đưa vào nối với thư mục /static
bằng join, sau đó đường dẫn file liên tục đưa vào hàm normpath và kiểm tra xem file có tồn tại không (isfile), có đọc được không (access) và kiểm tra độ lớn file có nhỏ hơn 2MB không, nếu có thì bắt đầu đi kiểm tra tiếp.
Tiến hành mở file đó ra với filename đưa vào normpath, filename bị kiểm tra bằng một chuỗi regex rất to và quá trình kiểm tra được đặt timeout là 2s, và kiểm tra nếu chữ flag
có trong filename không, thỏa mãn sẽ đọc file, còn không sẽ bỏ qua.
Sau khi mình đọc source, mình thấy có vài thứ khả nghi:
"flag" not in filename
quá trí mạng và chưa có cách bypass, nghi vấn có khả năng không đọc bằng cái hàm file.read đằng sauBan đầu mình cố gắng để bypass đoạn "flag" not in filename
bằng cách tìm vuln của hàm normpath vì mình thấy hàm này lạ, và thực sự nó có một CVE path traversal với hàm normpath này: CVE-2023-41105, mình đã thử cách này nhưng python của challenge là python 3.12, còn phiên bản dính lỗi là từ 3.11 đến 3.11.4, nên cách này ta bỏ.
Sau đó mình tiếp tục thử bypass cái if kia bằng hex encode nhưng dấu \
khi gửi sẽ biến thành \x5c
và khi vào hàm normpath không render ra được chữ, nên cách này cũng không khả thi.
Sau đó mình rất bí và không nghĩ ra được cách nào nữa, cho đến khi hint thứ nhất xuất hiện, hint đầu tiên là cách đọc file từ file descriptor: Reads from the file descriptor. Quả hint này làm mình tốn rất nhiều thời gian để hiểu, sau khi đọc đi đọc lại và mình mang cái script trong ví dụ kia, mình nôm na nghĩ là khi mình tạo ra tiến trình đọc một file thì tiến trình đọc file đó sẽ xuất hiện vào luồng file descriptor của tiến trình. Trong linux mình có thể biểu diễn mọi thứ thông qua hệ thống file, được thể hiện trong folder /proc/{pid}/fd
, pid là số thứ tự của tiến trình đó, bên trong thư mục này tiếp tục sẽ có các số nguyên biểu thị cho các luồng dữ liệu có trong tiến trình, trong đó mặc định có 0-stdin, 1-stdout, 2-stderr, ngoài ra còn có thể có các luồng khác tùy với tiến trình đó tương tác như thế nào với các file trong Linux.
Một câu hỏi to đùng của mình đặt ra lúc đó là server đâu có động đến được file /flag.txt -> làm sao để nó vào được một luồng trong fd của tiến trình, và làm sao để mình biết được số nguyên đó?
Chính bởi vì lúc đó mình không hiểu bản chất của /proc/{pid}/fd
, mình đã nghĩ rằng nó sẽ bay vào các luồng có trong tiến trình chạy flask nếu như mình spam request read file ?file=/flag.txt
, nhưng nó không hoạt động như thế.
Sau khi kết thúc cuộc thi, mình được người anh em Twil4 gửi cho doc nói về thư mục /proc này, đây là một bài đọc rất chi tiết về các subfolder có trong /proc: https://www.anquanke.com/post/id/241148
, mình để ý có một đoạn như này
Đây là ví dụ về một bài ctf, trong bài đó challenge mở và đọc file /tmp/secret
sau đó xóa file, và author đã giải thích rằng trong Linux nếu một chương trình mở một file bằng open() và không đóng nó ở cuối thì dù sau khi xóa file thì nội dung vẫn tồn tại ở trong thư mục file descriptor - fd tại tiến trình đó. Bằng file descriptor, ta vẫn có thể đọc nội dung của file đã bị xóa khi biết được pid của tiến trình, từ đó author Intruder để lấy nội dung file và solve challenge.
Vậy là khi open file là file đó được đưa vào thư mục fd của tiến trình đó, và nó sẽ tồn tại cho đến khi ta kết thúc quá trình đọc file, hoặc là close nó.
Khoan, challenge rõ ràng đã đọc file mà ta chỉ định rồi mới đưa vào kiểm tra và quyết định xem có đọc file hoặc bỏ qua hay không. => Vậy mình có thể đọc nội dung của file đó nếu như mình biết được pid và số hiệu luồng dữ liệu.
Để thử thì mình sẽ nhồi thêm câu lệnh sleep vào source code sau khi đọc file để server dừng lại đủ lâu cho mình quan sát:
Mình gửi request đọc file flag cho server:
Server đang sleep nên mình đi vào tiến trình trong folder /proc để xem thì mình thấy luồng số 10 của tiến trình 7 là symlinks đến file flag.txt
Mình đọc file đó thì cũng tương ứng với việc đọc file /flag.txt:
Và sau khi server xử lý xong thì luồng đó biến mất, chứng tỏ đoạn code with open
khi chạy hết sẽ tự động đóng lại quá trình mở và đọc file.
Mình deploy đi deploy lại trên local thì lần đầu lúc nào cũng sẽ là /proc/7/fd/10, nên mình nghĩ rằng nó sẽ vào luồng số 10 :))
Tổng kết lại, mình cần kéo dài nhiều thời gian nhất có thể để thử xem luồng nào là luồng đang chứa flag, vì ngoài server thật mình làm gì được sleep(200).
Vốn đã không hiểu đoạn regex dùng để làm gì, nhưng giờ mình mới nhận ra nó có thể giúp mình kéo dài thời gian với cái thuộc tính timeout=2, giờ mình cần tạo 1 cái filename dài, rất dài để quá trình xử lý bằng regex càng lâu càng tốt, trong lúc đó mình cần phải thử để xem pid nào đúng, từ đó lum flag.
Mình cần 2 request để thực hiện giải quyết challenge, với request câu thời gian mở file, mình sử dụng nhiều dấu path traversal để độ dài của biến file là lớn nhất có thể:
Request này của mình lúc nào gửi cũng mất 2 giây, trong lúc đó mình gửi request thứ 2 để tìm luồng đang chứa nội dung file flag:
Sau khi thử một hồi thì mình cũng có được flag:
Kết quả của mình trên server:
Flag của challenge: KCSC{D1eu_tuу3t_v01_n@m_o_n0i_ch1nh_ta_ch@ng_can_tim_d@u_xa}
Đây là một trong những bài có trick khá hay mà mình không nghĩ đến, mình cũng đã tốn kha khá thời gian cho challenge này và không có kết quả gì nên đành đi làm bài khác:
Flag là một biến môi trường, vậy để solve chall mình cần đọc /proc/self/environ hoặc thực thi câu lệnh để xem các biến môi trường của challenge
Ta cũng chỉ có một file app.py chứa 2 path khá cơ bản, mình sẽ đến với path "/" trước:
Trang chủ được sử dụng để list ra các file có trong path uploads/
, sử dụng hàm list_all_files để hiển thị file
Tại path /upload
, ta được upload 1 file zip, sau đó server lấy ra extension của file bằng os.path.splitext
, kiểm tra trong hàm fileIsSafe -> loại bỏ hầu như gần hết các đuôi file có thể upload trong python. Sau đó lấy các thư mục của file có trong thư mục zip, nếu chúng chưa tồn tại sẽ khởi tạo thư mục mới đó trong uploads. Sau đó chương trình ghi nội dung của file có trong file zip bằng cách đọc nội dung file trong file zip và viết vào đường dẫn tương ứng ở trong uploads/
. Nghe có vẻ là một cách hay thay cho việc extract.
Mình đã thử symlink và nén vào file zip rồi upload lên nhưng kết quả không mấy khả quan. Sau khi thấy việc upload file zip nhưng không extract mà dựa vào tên file để đọc và lấy nội dung, mình nghi ngờ việc liệu server có thực sự dính zip slip không? Lúc đó mình chỉ chăm chăm đi tìm cách để đọc file /proc/self/environ bằng symlink và không đạt được kết quả gì.
Ngồi thử một hồi không được, mình quyết định chuyển qua exploit bằng filename, vì chương trình dựa vào tên file của file trong file zip để đọc nội dung nên mình nghĩ có thể truyền vào tên file dạng ../../../../../../proc/self/environ
để đọc, nhưng cách nghĩ này sai ở 2 chỗ:
Sau đó mình đã không nghĩ ra được cách nào khác, vì cứ đinh ninh rằng hàm kia đã cấm .py, .html thì còn upload ghi đè với zip slip gì nữa. Nhưng mình đã nhầm
Người bạn của mình là Chuong đã bảo rằng vẫn có thể zip slip, và vẫn có thể ghi đè được file html.
Mình tìm đến Zip slip example và tải file zip-slip.zip về để upload, và mình nhận ra mình đã nhận định sai. Mình vẫn có thể zip slip vào phần filename:
Vào phần file mình thấy evil.txt được ghi vào trong tmp, chứng tỏ có thể khai thác zip slip:
Và hơn nữa, mình có thể bypass hàm kiểm tra bằng cách ghi đè file html bằng HTML
Như vậy, mình sẽ khai thác zip slip với filename có dạng ../../../../../../../../../app/templates/index.HTML
rồi SSTI tại file html đó để RCE lấy flag:
Đầu tiên mình sẽ tạo file index.HTML tương ứng trong kali:
Mình chèn vào file html payload SSTI kèm câu lệnh env để hiển thị tất cả các environment variables:
Sau đó mình zip file lại:
Vì ghi đè html nên mình chọn cách curl để lấy thông tin về cho tiện, và cách này chỉ có thể làm lần đầu khi chưa render web vì nếu đã vào thì website sẽ lưu lại template cũ mà không render template mới của mình:
Exploit thành công