内容纲要

这是之前的题目了 , 当时出去玩没做 , 这两天空闲静下心来看一看题目 .

题目很难 , 我花了一整天时间也没有绕过那个 baidu.com 的主机名限制 , 至于后面的敏感函数过滤也看了下 , 感觉也非常复杂 .

虽然最后也没有拿到完美的解法 , 但我从这道题中学会了很多 , 还是可以记录一下的 !

Boring Code

题目是这个样子的

很明显的一道代码审计题目 , 来看一下具体的流程

  1. 先从 POST 方法获取到参数url的值 , 若不存在参数值则显示页面源码 , 这没啥好说的

  2. 对获取到的 url 参数值进行处理 , 先通过 filter_var() 函数判断其是否是一个合法的URL , 若合法则对其进行正则过滤 , 若 url 使用 data:// 这个 PHP 伪协议 , 则返回 False , 否则返回 True .

    这里要注意一下 filter_var() 这个函数 , 该函数使用特定的过滤器来过滤一个变量 , 其中 FILTER_VALIDATE_URL 就是一个常用的过滤器 , 只有符合 (英文字母 , 数字)://(英文字母 , 符号 , 数组) 这样格式的URL才能通过过滤

  3. 若该 url 通过了 filter_var() 函数的检测 , 则对其使用 parse_url() 函数进行解析 , 并通过正则表达式判断解析后得到的 host 值是否以 baidu.com 结尾, 若成立则通过 file_get_contents() 函数获取指定url的内容 , 若不成立则报错跳出 .

  4. **若成功获取到了指定url的内容 , 则对内容进行正则过滤 ( 一共进行了两次相当严格的过滤 ), 若通过过滤则通过 eval() 函数执行url , 若未通过则返回报错并且退出 .

整体的看下来 , 这是一道绕过各种防御措施来拿到RCE的题目


两道防线

我认为此题应该分为两道防线 , 一道位于获取 url 参数上 , 一道位于处理 url `参数上

第一道防线由 filter_var() , parse_url() , preg_match() , file_get_contents() 四个函数组成 , 需要我们指定一个合法的 , 内容没有使用 data://伪协议的 , 主机名以 baidu.com 结尾的 url .

第二道防线由 preg_replace()preg_match() 两个函数组成 , 第一个正则过滤指明从url获取的内容由多个无参函数组成 , 第二个正则过滤指明了不可使用的函数 .

这两道防线都很难绕过 , 我们慢慢来分析


第一道防线

第一道防线猜测是一个 SSRF , 因为需要url解析后的主机名以 baidu.com 结尾 . 但首先 , 我们要先绕过 filter_var() , parse_url() , preg_match() 这三个函数 .

为了不受第二层防线的影响 , 这里将第一层防御中使用的函数提取出来

只要能通过这个脚本 , 就代表突破了题目中的第一层防御

关于如何绕过这三个函数 , 你可以在网上找到很多内容 .

  1. PHP SSRF Techniques How to bypass filter_var(), preg_match() and parse_url()

  2. SSRF和XSS-filter_var(), preg_match() 和 parse_url()绕过学习

  3. SSRF 学习笔记

绕过 filter_var() 很简单 , 如果你了解 URL RFC 语法 , 就知道分号( ; ) , 逗号( , ) , 反斜杠( \ ) 是关键 . 比如你可以参考 filter-var-bypass

但接下来呢 ? 上述这些文章主要是针对 exec(curl -s -v) 以及 file_get_contents() 这两种请求方式进行分析 ,

针对 exec( curl -s -v ) 这种请求方式 , 绕过 parse_url() , preg_match()并不困难 , 主要是利用了parse_url()libcurl 对url 的解析差异 .

并且 , 在面对 file_get_contents() 函数时 , 我们只知道有一种方法可以绕过上述三个函数的限制 , 那就是利用 data://伪协议实施XSS . 但不幸的是 , data://伪协议已经在 is_valid_url() 中被过滤掉了 .

所以最后你会遇到这样的情况 , 纵使你成功绕过了前三个函数 , 最后依旧会卡在 file_get_contents() , 因为你构造的url无法被该函数识别 , 也就无法去访问 .

因此 , 现在网上能找到的绕过姿势其实不能成功利用 , 我们需要针对实际环境去思考新的方法 . 下面我总结了 Google 上主流的几种解题思路 , 并研究一下它们的可行性 .


购买一个 xxxxbaidu.com 的域名( 理论可行 )

这种方法是网上最主流的解法 , 因为它快速且简单 , 仅需要购买一个域名就可以绕过第一层防线 , 非常简单 .

但此时是为了研究学习 主要还是舍不得买, 就不把这种方法作为主要方法了


百度网盘链接( 可行? )

这种方法的思路是将恶意代码上传到百度网盘 , 然后通过百度网盘的下载链接来绕过 baidu.com 的主机名限制 .

  1. 将一个恶意脚本上传到百度网盘( 这里以 phpinfo() 为例 )

  2. 通过开发者工具( F12 ) , 在 network 选项卡中可以找到目标文件的链接

    这个链接刚好符合我们的要求~

  3. 右键复制链接 , 然后放入一个测试脚本中 , 发现可以成功拿到我们构造的恶意代码 !

    利用该方法可以完美突破第一层防御~


百度贴吧( 可行? )

Google 上说这里利用了百度贴吧上的外链任意跳转 , 可以参考 (tieba)post.baidu.com跳转链接的生成方法 , 我就不多说啦 .

  1. 首先先去百度贴吧上的一个小吧发一个附带你服务器上恶意脚本的链接 .

    顺带一题 , 不清楚这个百度贴吧的过滤机制是咋样的 , 每次我发的帖子都会被秒删除 . 然后我咨询了宿舍混迹贴吧多年的室友 , 他告诉我发帖一定要带图 . 然后我试了下 , 果然不会被删哈哈~~

  2. 然后通过 BurpSuite 拦截该链接的请求

    就是这个数据包 , 帖子里说这里 host 可以改成 post.baidu.comtieba.baidu.com , 且都不会影响数据包的发送 , 于是我改成了 tieba.baidu.com , 然后提交 .

    最初它会返回什么链接不安全 , 需要重新点击的 , 但是当我写这篇文章时 , 百度已经把这个链接作为安全链接了 , 可以直接访问 .

    我不清楚这个原因是什么 , 可能是因为我昨天点击了下面的误报申诉或者是在百度站长那里提交了链接 , 也可能因为时间长了就作为安全链接了 . 但这可以作为一种解题思路!

  3. 可以通过之前的测试脚本来看一看 file_get_content() 函数能否获取到我站点的恶意脚本( 别忘了将 host 修改为 tieba.baidu.com )

    成功拿到我们的恶意脚本 , 这种方法也可以完美绕过第一层防御 .


ftp ( 缓缓打出一个问号? )

这个思路是在合天网安那里看到的 2019 bytectf writeup

其中第三点我已经证实过 , 是可行的 , 但是第二点这个 FTP ...

可能我太菜了 , 没怎么了解过 FTP , 我真的没看懂第二点到底是个啥原理 , 自己实验也没有成功 .

如果哪位师傅知道如何解决这个问题 , 欢迎留言~谢谢~


百度爬虫( 理论可行 , 未实践 )

这个思路是在 ROIS 师傅那里看到的~

ByteCTF 2019 线上赛 Writeup By ROIS

其原理大概就是 : 百度的搜索引擎爬虫会爬到你的个人站点 , 当你在百度上点击自己站点时 , 并不是直接访问 . 而是利用百度的重定向机制 , 将你的网址转换成 http://www.baidu.com/link?url=xxxxxxxxxxxxxxx , 通过这个链接可以绕过第一层防御并且拿到你站点上的恶意脚本 .

然而 == 百度并没有收录我的站点 ... 这都好几个月了 ... 这个点以后再说吧 , 现在无法复现了 .


行了 , Google 上的思路差不多就是这五种 , 你有足够的方法突破第一层防线 , 这里就不多说了 , 我们来看一看第二层限制~


第二道防线

和前面一样 , 为了不受到第一层防线的影响 , 我们把第二层防线用到的函数提取出来 , 单独处理 .

首先要通过一个正则表达式 , 该正则表达式中 (?R)DEELX 正则表达式扩展语法 中的递归表达式 , 表示递归引用整个模式 . 而 [a-z]+ 表示出现一次或者多次小写字母 .

然后还要突破一个正则表达式 , 这里过滤掉了非常多的字符组合( 也就是过滤掉了很多函数 ) .

其实 , 这里就是要让你通过构造一组类似 abc(def(ghi( ... ))); 这样无常量参数的函数 , 来读取系统的文件 . 原题中会告诉你 , flag 在父目录的 index.php 文件中 . 也就是目录的拓扑如下

我们看到的页面在 code 目录的 index.php 文件中 , 而目标 flag 位于父目录的 index.php 中 , 因此这里应该拿不到 shell , 仅需要你能构造出读取文件的 payload 就可以了

关于 PHP 无参函数实现 RCE , 可以参考 一叶飘零师傅的 PHP Parametric Function RCE 这篇文章 . 我自己也对此做过笔记 , 可以参考 PHP 无参数实现 RCE .

下面就来谈一谈这部分的解题思路吧~

  1. 先 Fuzz 一下还能使用的函数

    遇到这种过滤函数的题目 , 最先要做的就是看一看还有哪些可用的函数 .

    要注意 , 这里 Fuzz 的条件可不仅仅是 MARKDOWN_HASH0f75f75caecc92e2347d579a1eb64ffbMARKDOWNHASH , 还需要函数名仅由小写字母组成 , 像 " " , " - " 的等特殊字符就不能使用 . , 因此完整的的条件应该为 : /et|na|nt|strlen|info|path|rand|dec|bin|hex|oct|pi|exp|log|_|-/i

    所以 , 我们可以利用 get_defined_functions()函数获取所有被定义的函数 , 还可以指定 internal 来获取系统内置的所有函数 , fuzz 脚本如下所示

    一共有 244 个函数还可以使用 , 但根据题意还需要这些函数不能有常量参数 , 因此实际上可用的函数是不多的 .

  2. 思考一下如何读取文件

    要读取父目录的文件 , 我们肯定要构造 " .. " 这样的字符 , 第一个想法肯定是通过 dirname 到达父目录 , 但 dirname() 被过滤了 , 那么还可以使用 scandir(getcwd()) 函数来获取当前目录下的所有文件 , 也就可以拿到 " .. " , 从而进入父目录 . 但是 getcwd() 也被过滤了 . 因此我们需要通过其他方法来获得 " .. "

    获取到了 " .. " , 就可以通过 chdir() 函数改变当前的目录而到达父目录 , 由于 chdir() 函数的返回值为 TrueFalse , 因此这里可以用一个 if() 语句 , 当返回值为 True 时( 也就是成功定位到2父目录时 ) , 读取当前目录下的 index.php 文件 , 就可以了

    因此这里的难点就是如何获取到 " .. " , 但是我太菜了 , 搞了半天也没有拿到 " .. " , 最后看了大佬给出的 payload , 又学会了很多东西~

    下面来整理一下我学到的姿势吧~

    对了 , 为了方便测试 , 建议修改正则表达式 , 把 " _ " 给放出来( 不能使用 var_dump() 真的非常难受 )


crypt() 函数

该方法与我的想法类似 , 就是先拿到 " .. " , 再通过 if 语句读取父目录的内容 . 就可以拿到 Flag 了. 这就是差距啊!

补个链接 : byteCTF web wp+misc wp

  1. crypt(serialize(array()))

    首先定义一个数组 , 然后对其进行序列化操作 , 输出序列化字符串 , 这里没什么问题 . 然后就用到一个非常关键的函数 : crypt()

     crypt($str , [$salt]) : 返回一个基于标准 UNIX DES 算法或系统上其他可用的替代算法的散列字符串 . 

    说起来很复杂 , 你仅需要知道它可以返回一个加密字符串

    多次尝试后 , 发现 " . " 会出现在加密字符串的末尾( 加密字符串的开头默认为 : " $ " ) , 然后我才想到 , scandir(getcwd()) 不能用 , 但可以用 scandir('.') 啊 , 真的太菜了!

    下面的问题就是如何获取 " . "

  2. chr(ord(strrev()))

    因为加密字符串的 " . " 可能会出现在末尾 . 这里很容易想到 chr(ord()) 这个组合

     ord() : 解析 string 二进制值第一个字节为 0 到 255 范围的无符号整型类型( 不严禁的说就是将字符串第一个字符转换为 ASCII 编码 )
    
     chr() : 返回相对应于 ASCII 所0指定的单个字符 , 该函数与 ord() 是对应的~

    有了这两个函数 , 可以将字符串的第一个单独字符取出来 , 下面仅需要逆转字符串就可以了~

     strrev() : 反转字符串

    有了该函数 , 就可以配合使用 , 获取到 " . " 了

  3. chdir(next(scandir()))

    有了 " . " , 就可以利用 scandir()next() 获得 " .. " 了 .

     chdir() : 将 PHP 的当前目录改为指定目录 . 

    这里 chdir() 函数返回 true , 说明成功将当前目录修改为父目录 .

  4. readfile()

    其实这里的难点就在获取到 " .. " 上 , 下面的步骤是类似的 , 获取到 " . " , 读取到当前目录( 已经修改为父目录 )下的内容 , 然后读取到 index.php 文件就可以了 .

    然后就可以读取到 " index.php " 中的 flag 了

  5. if() 语句连接 , 形成完整的 Payload

    Payload : if(chdir(next(scandir(chr(ord(strrev(crypt(serialize(array())))))))))readfile(end(scandir(chr(ord(strrev(crypt(serialize(array()))))))));

    多次运行脚本后就可以拿到本地的 flag , 看起来很吓人 , 其实分析一下就很简单了~ 把这个脚本上传到百度网盘 , 然后就可以调用该脚本了 .

    然后我发现百度云盘( 百度贴吧 )这个方法其实是不可以的哈 , 后面会说为啥子~

来看一看其他的思路 , 因为这些思路都是先获取到 " . " , 因此我就不像上面这样具体说了 , 只谈思路 .


localeconv() 函数

补个链接 : ByteCTF 2019 WriteUp By W&M

核心思路是 : localeconv() 函数

     localeconv() : 返回一个包含本地化数字和货币格式设置信息的关联数组 . 

直接来看输出

数组的第一位就为 " . " , 然后虽然 current() 函数被禁用了 , 但我们还可以使用 pos() 函数

     pos() : current() 的别名

不用多说了吧 , 可以通过 chdir(next(scandir(current(localeconv())))) 进入父目录 .

此时我们已经切换到了父目录 , 现在需要去读取父目录下的 index.php 文件 . 这里你不想用 if 语句也没事 , 还有一种方法 : time()+ localtime() 函数

     time() : 返回自从 Unix 纪元( 格林威治时间 1970 年 1 月 1 日 00:00:00 )到当前时间的秒数 , 也就是返回一个时间戳

     localtime() : 以数值数组和关联数组的形式输出本地时间 . 

     time() 的参数为 void 也就是说引入任意的参数都不会影响 , 其输出( 不用去管那个警告 ) , 但是返回的时间戳无法成为" . "

     localtime() 数组,可以提取出秒数的值,用chr转换为字符串 ” . ” 即在 46s 时 <code>chr(pos(localtime()))</code> 就会返回 ” . ”

     localtime() 的第一个参数默认为时间戳 , 也就是 <code>time()</code> 的返回值 . 

根据以上这些内容 , 我们可以通过 time() 接收 chdir() 的返回值( 返回什么不会对结果有任何影响 ) , 再用 localtime() 接收 time() 的返回值 , 再用 chr(pos()) 接收 localtime() 的返回值 , 从而在切换目录的情况下拿到 " . "

然后就能读取到 父目录的 index.php 文件了

Payload : echo(readfile(end(scandir(chr(pos(localtime(time(chdir(next(scandir(pos(localeconv()))))))))))));


phpversion() 函数

该方法比较考验数学功底 -- 大佬们真是太强了 .

补个链接 : ByteCTF 2019 WriteUp Kn0ck

核心思路是 : phpversion() 函数会返回当前PHP的版本好 , 然后可以用 floor() 函数取第一位的数值( 固定为 7 )

     floor() : 返回不大于 x 的下一个整数 , 简单的说就是向下取整

有了数字 " 7 " , 就可以通过各种数学运算拿到数字46 , 也就是ASCII字符 " . " .

给个payload : ceil(sinh(cosh(tan(floor(sqrt(floor(phpversion())))))))

     sqrt() : 返回一个数字的平方根

     tan() : 返回一个数字的正切

     cosh() : 返回一个数字的双曲余弦

     sinh() : 返回一个数字的双曲正弦

     ceil() : 返回不小于一个数字的下一个整数 , 也就是向上取整

经过上面这些步骤 , 能拿到数字 46

再通过 chr() 函数就可以返回 ASCII 编码为 46 的字符 , 也就为 " . " , 后面的步骤就和之前一样 , 跳转到根目录 , 然后读取 index.php 文件 .


总结

这道题非常的有意思 , 绕过限制的各种方法也非常有趣 , 可以再多加研究 . 在这里非常感谢各位师傅的 WriteUp , 对我帮助很大 !

百度云盘( 百度贴吧 )的限制

之前说百度网盘( 百度贴吧 )也存在一个问题 , 下面来说下这个问题 , 问题就在下个界面中

可以看到 , 我上传的字符串内容只有一行 , 但是那个下载链接里却额外添加了一行空行 !

正是这个空行 , 让整个字符串无法通过 preg_replace() 的检测 , 从而使得整个攻击脚本不成功

针对这个问题 , 我在 这个链接 中看到了一个解决方式

然而我并不明白是啥意思 -- 解决者是使用购买的域名 , 而非使用 百度网盘 或者 百度贴吧

如果您知道解决方法 , 欢迎留言~ 先在这里表示感谢 !

最后修改日期:2020年3月9日

作者

留言

撰写回覆或留言

发布留言必须填写的电子邮件地址不会公开。