0%

2023DownunderCTF-writeup

DownunderCTF 2023 writeup

前言

这比赛周末打的,web题目做出来一半(简单的那一半),medium以上的一道题目没做出来,名次由于这次只有我一个人参加,而我只会做些web和misc以及一点点crypto,逆向和pwn完全不会,而且国外的套路和国内还是不一样啊。最终排名也是泯然众人,中间名次。看了看后来的wp,简单的写一些这次收获到内容的一些题目吧。

这个比赛很好的一点在于提供了所有的题目环境,下次再出题正好改改。😂

题目环境地址:https://github.com/DownUnderCTF/Challenges_2023_Public/

题目

web

smooth-jazz

题目代码如下:

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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
<?php
function mysql_fquery($mysqli, $query, $params) {
return mysqli_query($mysqli, vsprintf($query, $params));
}

if (isset($_POST['username']) && isset($_POST['password'])) {
$mysqli = mysqli_connect('db', 'challuser', 'challpass', 'challenge');
$username = strtr($_POST['username'], ['"' => '\\"', '\\' => '\\\\']);
$password = sha1($_POST['password']);

$res = mysql_fquery($mysqli, 'SELECT * FROM users WHERE username = "%s"', [$username]);
if (!mysqli_fetch_assoc($res)) {
$message = "Username not found.";
goto fail;
}
$res = mysql_fquery($mysqli, 'SELECT * FROM users WHERE username = "'.$username.'" AND password = "%s"', [$password]);
if (!mysqli_fetch_assoc($res)) {
$message = "Invalid password.";
goto fail;
}
$htmlsafe_username = htmlspecialchars($username, ENT_COMPAT | ENT_SUBSTITUTE);
$greeting = $username === "admin"
? "Hello $htmlsafe_username, the server time is %s and the flag is %s"
: "Hello $htmlsafe_username, the server time is %s";

$message = vsprintf($greeting, [date('Y-m-d H:i:s'), getenv('FLAG')]);

fail:
}
?>
<!DOCTYPE html>
<html>
<head>
<title>🎷 Smooth Jazz</title>
<style>
body {
background-color: #f8f8f8;
font-family: Arial, sans-serif;
}

.container {
max-width: 400px;
margin: 100px auto;
padding: 20px;
background-color: #fff;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
text-align: center;
}

h1 {
color: #333;
}

form {
margin-top: 20px;
}

label, input {
display: block;
margin-bottom: 10px;
}

input[type="text"],
input[type="password"] {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}

input[type="submit"] {
width: 100%;
padding: 10px;
background-color: #4287f5;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}

.music-player {
margin-top: 20px;
}

h2 {
color: #333;
}

audio {
width: 100%;
margin-top: 10px;
}

.message {
margin-top: 10px;
}
</style>
</head>
<body>
<div class="container">
<h1>Smooth Jazz</h1>
<form method="post">
<label for="username">Username:</label>
<input type="text" id="username" name="username" placeholder="Enter your username">

<label for="password">Password:</label>
<input type="password" id="password" name="password" placeholder="Enter your password">

<input type="submit" value="Login">
</form>
<div class="music-player">
<audio src="/offering-larry-stephens.mp3" id="audio"></audio>
If you are stuck, you can <a href="javascript:document.getElementById('audio').play()">listen to some smooth jazz</a>.
</div>
<div id="message" class="message">
<p><?= $message ?? '' ?></p>
</div>
</div>
</body>
</html>

先说下整体逻辑,

1、先判断用户名是否存在

2、sql查询对应用户名、密码是否存在在数据库

3、最后$username还要强等于(===)admin 才能拿到flag

其实一开始我以为是宽字节注入呢,因为我构造admin%df’进入发现可以绕过第一步,但是怎么都无法注入,后续查看数据库编码也没有gbk的编码形式。其实这里涉及的是另一个考点:

UTF截断:UTF截断会截断无效内容,比如 admin和admin%ff 应该是一样的。

所以我这里可以通过这种方法绕过第一步,但这里不是宽字节注入。而且后续还要强等于admin,这个===印象中在非特殊环境下是不可绕过的。而且根据代码来看,注入点应该在第二个查询语句中。

现在好像是无解的,但是代码最开头的地方有一个vsprintf,比赛中我也忽略了,我以为考点在下面,这里的vsprintf 根据格式字符串中的格式指示符,常见用法就是:

1
2
3
4
5
6
7
8
9
10
# %[argnum$][flags][width][.precision]specifier.
# 必须参数就是%specifier 中间都是可选参数
#一些用法
# %表示要被格式的参数 后面跟类型 %s就是字符串
$string = vsprintf('Hello, %s! Today is %s.', ['John', 'Monday']);
# %1$s 表示第一个字符要被格式成字符串,%2$d 表示第二个字符要被格式成数字。
$result = vsprintf('Name: %1$s, Age: %2$d', ['John', 25]);
# %1$'a10 表示单引号后的第一个字符要填充 填充10次。然后拼接后面的3
$result = vsprintf("Like %1\$'a10s", ["3"]);

当然还是建议去看下官方文档,详细了解所有用法:

https://www.php.net/manual/zh/function.vsprintf.php

如果我们插入一个%1$c 然后把password 的 sha1 生成一个34开头的字符串(char类型只拿前面的一个char格式化字符串):

![image-20230906145114573](/Users/geez/Library/Application Support/typora-user-images/image-20230906145114573.png)

那么我们就格式化字符串的时候就拿到一个双引号,也就是我们可以闭合了。

那我们的注入payload就可以是

1
username=admin%ff%1$c||1#&password=668

\xff 是为了utf8截断从而绕过sql查询部分的admin的判断,截断后面的东西都不会被和admin比较了

![image-20230906151947718](/Users/geez/Library/Application Support/typora-user-images/image-20230906151947718.png)

但是还是有一个问题,怎么突破===’admin’强等判断打印flag呢?

1
2
3
4
5
6
$htmlsafe_username = htmlspecialchars($username, ENT_COMPAT | ENT_SUBSTITUTE);
$greeting = $username === "admin"
? "Hello $htmlsafe_username, the server time is %s and the flag is %s"
: "Hello $htmlsafe_username, the server time is %s";

$message = vsprintf($greeting, [date('Y-m-d H:i:s'), getenv('FLAG')]);

这里仍然利用vsprintf,我们既然绕不过===”admin” 能不能在格式化字符串的时候再创建一个变量放置位把getenv(‘FLAG’) 打印出来?

如果我们直接插入%2$s 那么在sql注入格式化字符串的时候就会报错,因为那里的参数只有一个[password],我们需要的是成功执行sql注入并在最后一个vsprintf中能拿到类似%2$s的字段来把flag格式化写进去,那么我们需要第二个变量位置它在拼接sql语句时不进行格式化,在最后一个vsprintf处把flag格式化进去:

这步的关键在于htmlspecialchars

1
2
3
4
5
6
$htmlsafe_username = htmlspecialchars($username, ENT_COMPAT | ENT_SUBSTITUTE);
//这行代码将 $username 变量的值进行 HTML 转义,并将转义后的结果赋给 $htmlsafe_username 变量。
//第一个参数 $username 是需要进行转义的字符串,它的值被传递给 htmlspecialchars() 函数进行处理。
//第二个参数 ENT_COMPAT | ENT_SUBSTITUTE 是转义模式,用于指定转义的规则。
//ENT_COMPAT 表示只转义双引号,将双引号转换为 &quot;。其他特殊字符不转义。
//ENT_SUBSTITUTE 表示将无法转义的字符用 Unicode 替代符号替代,而不是忽略或删除它们。

利用 > 被编码为&gt 我们可以构造 %1$’>%2$s

$’是什么意思我们可以看官方手册:

![image-20230907135735471](/Users/geez/Library/Application Support/typora-user-images/image-20230907135735471.png)

也就是上面的字符串要填充 > 但是没写数量所以填充0个就是空了,%1$’>在vsprintf处理时就会变成空,只剩下%2$s

sql语句变成:

1
SELECT * FROM users WHERE username = "admin"||1#%2$s" AND password = "34c66477519b949b09b45e131347c17b5822a30a"SELECT * FROM users WHERE username = "admin"||1#%2$s" AND password = 

这里解释下为啥%1$’>%2$s 不是按照我们理解的应该两个参数都格式化进去,关键还是文档。

%[argnum$][flags][width][.precision]specifier.

官方文档说明这个函数的使用方式如下,其中%和specifier是必须的 也就是说%s是极简模式。我们使用%1$’< 时 有没有发现这个格式的specifier应该是什么?

没有,对没有specifier,所以%1$’>%2$s 就把% 当作specifier了 这个%也就逃逸出来了:

![image-20230908094433300](/Users/geez/Library/Application Support/typora-user-images/image-20230908094433300.png)

其实输出%需要%%的转义和这个原理几乎一摸一样。

后续username需要传入htmlspecialchars($username):

1
admin%1$c||1#%1$'&gt;%2$s

此时拼接完的参数是:

1
2
3
4
5
$greeting = Hello admin%1$c||1#%1$'&gt;%2$s, the server time is %s
$message = vsprintf($greeting, [date('Y-m-d H:i:s'), getenv('FLAG')]);
1、%1$c 被一个字符填充
2、%1$'&g 被 2023 填充,因为&后面没有数字,所以没有使用&填充,后面specifier标志为为g表示通用格式。可以看官方手册了解详情
3、%2$s 被真正的flag填充 最后的%s第一个时间字符串填充

最终我们拿到flag

![image-20230908094908451](/Users/geez/Library/Application Support/typora-user-images/image-20230908094908451.png)

扩展

上面的$’ 填充使用方式还可以在一些转义逃逸中使用,比如在sql语句中经常有需要闭合的’ 而代码转义了’ 导致我们无法闭合,那么我们可以构造:

1
%1$'or(1=1)#

经过转义变为

1
%1$\'or(1=1)#

而经过vsprintf时,单引号就逃逸出来了

![image-20230908115035624](/Users/geez/Library/Application Support/typora-user-images/image-20230908115035624.png)

cgi-friday

直接看题目

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
#!/usr/bin/env perl

use strict;
use warnings;
use CGI::Minimal;

use constant HTDOCS => '/usr/local/apache2/htdocs';

sub read_file {
my ($file_path) = @_;
my $fh;

local $/;
open($fh, "<", $file_path) or return "read_file error: $!";
my $content = <$fh>;
close($fh);
return $content;
}

sub route_request {
my ($page, $remote_addr) = @_;

if ($page =~ /^about$/) {
return HTDOCS . '/pages/about.txt';
}

if ($page =~ /^version$/) {
return '/proc/version';
}

if ($page =~ /^cpuinfo$/) {
return HTDOCS . '/pages/denied.txt' unless $remote_addr eq '127.0.0.1';
return '/proc/cpuinfo';
}

if ($page =~ /^stat|io|maps$/) {
return HTDOCS . '/pages/denied.txt' unless $remote_addr eq '127.0.0.1';
return "/proc/self/$page";
}
return HTDOCS . '/pages/home.txt';
}

sub escape_html {
my ($text) = @_;

$text =~ s/</&lt;/g;
$text =~ s/>/&gt;/g;

return $text;
}

my $q = CGI::Minimal->new;

print "Content-Type: text/html\r\n\r\n";

my $file_path = route_request($q->param('page'), $ENV{'REMOTE_ADDR'});
my $file_content = read_file($file_path);

print escape_html($file_content);

首先理清楚代码逻辑:

1、$q->param(‘page’)接受传入的page参数、$ENV{‘REMOTE_ADDR’}拿到请求的ip 一般认为这个是不可伪造的。

2、route_request处理上述请求,当满足以下两点可以拿到flag

1
2
3
4
5
6
if ($page =~ /^stat|io|maps$/) {
return HTDOCS . '/pages/denied.txt' unless $remote_addr eq '127.0.0.1';
return "/proc/self/$page";
}
# 1、page参数是包含io
# 2、$remote_addr 等于 127.0.0.1

第一个好绕过,第二个直接想改变$ENV{‘REMOTE_ADDR’}是困难的,但是$q->param(‘page’)可以接收一个list而不是一个参数,所以我们可以构造两个page的数组一个用来赋值$page,一个用来赋值给$remote_addr

最终payload:

1
/?page=../../sys/module/vfio/../../../flag.txt&page=127.0.0.1

拿到flag:DUCTF{s qqjust another perl hacker q and print ucfirst}


采用署名-非商业性使用-相同方式共享 4.0(CC BY-NC-SA 4.0)许可协议
「分享也是一种学习」