代码审计思路
在审计之前我们需要了解一下代码审计常见的三种思路:
- 全文通读:通读入口文件代码,了解程序架构、运行流程、包含文件等。
- 敏感函数回溯:根据敏感函数来逆向追踪参数的传递过程,是目前使用最多的方式,因为大多数漏洞是由于函数的使用不当造成的。
- 功能点定向审计:了解程序的功能后,结合黑盒测试进行定向审计。
推荐书籍:代码审计-企业级 Web 代码安全架构
目录结构
在进行代码审计之前,我们先来了解一下目录结构,这有助于理解程序的代码逻辑。
apps:应用程序目录,主要用于存放程序的模型、视图、控制器。
config:配置文件目录,包含程序配置文件、数据库配置文件、路由配置文件。
core:核心类库,包含验证码、数据库、日志等。
data:数据目录,默认存在 Sqlite 数据库文件(系统默认使用 Sqlite 数据库,也可启用 MySQL 数据库)
doc:文档目录,存放程序的更新日志(通常我们可以通过阅读更新日志来了解程序曾出现过一些漏洞作为参考)
rewrite:重写目录,包含服务器的一些配置文件。
runtime:程序运行过程中创建,包含 Session、配置、缓存等信息。
static:静态文件目录,存放系统的静态资源(图片等)
template:模板目录,包含前端显示的相关代码。
admin.php:管理后台入口文件
api.php:API 入口文件
index.php:用户前端入口文件
全文通读
index.php
首先我们打开 index.php 文件,看看用户前端入口文件的代码逻辑。
首先程序第 11 行定义了 IS_INDEX 常量,其作用是入口文件检测。第 14 行又定义了 URL_BIND 常量,绑定入口文件地址。随后在第 17 行检测了 PHP 的版本,如果小于 5.4 则直接报错。在第 23 行引入了内核启动文件,那么我们去进行跟进,查看 /core/start.php 的代码逻辑。
可以看到在第 11 行程序引入了初始化文件,那么我们继续跟进 ./init.php 文件。
从这里我们就可以看到程序的代码已经变得逐渐复杂起来了,静下心来慢慢读。首先第 9-11 行,引入了相关的命名空间。随后第 14-22 行,定义了一些常量及字符集编码之类的配置。
第 25-27 行代码主要的目的是通过将 PATH_INFO(真实脚本名称之后并且在查询语句之前的路径信息)替换为空以获取当前脚本的路径。
技巧:我们可以通过自行添加代码,打印出变量的值帮助我们理解代码逻辑。
访问 http://127.0.0.1:8002/index.php/a/b?c=1,可以看到我们当前访问的脚本路径为 /index.php(虽然代码在 init.php 中,但是我们最初是通过index.php过来的)
随后第 48-81 行都是系统在定义一些常量,我们可以直接扫一眼掠过,继续往下读。
第 84-89 行程序继续包含了函数库文件、基础类文件,我们来看一眼这些文件。
handle.php
helper.php
file.php
Basic.php
可以看出,这些文件的内容都是一些定义好的方法。我们暂时不需要管它,回到 init.php 继续往后读(看一眼这些文件的目的就是看看有没有构造方法存在)
第 92-114 行,又是进行了一些设置(错误处理、异常捕获、自动加载..)以及定义常量。
第 117-118 行检查相关配置,我们跟进看一下。
程序进行相关检查,如果不支持扩展则报错,继续查看checkBasicDir的功能。
首先第 76 行向 get 方法传入 debug 参数获取配置参数,我们跟进 get 方法。
第 15 行 定义了 configs 变量,在第 21 行进行了判断。由于定义变量时未对其进行赋值,导致第 21 行的 if 判断为 true,加载 loadConfig 方法。
我们跟进loadConfig方法查看,方法的作用就是判断系统关键文件是否存在,若存在则进行包含。
mult_array_merge 函数的功能是将多维数组进行合并。返回 get 方法继续往下读。
由于 $item 参数等于我们传入的 debug,所以第 25 行的 if 不会进入,28 行将其变为数组。
第 29 行检测 $configs[debug] 是否定义,我们还是可以使用 var_dump 函数来查看一下它的值。
浏览器访问,发现为 false,方法直接返回空。
我们返回 checkBasicDir 方法继续读,第 76 行程序返回空。
第 84-89 行,程序使用自定义的 check_dir 函数判断目录权限,跟进 check_dir 函数。
可以看到很简单,判断一个路径是否存在并接受一个参数,如果存在则返回 true,根据 create 参数决定不存在是否进行创建。
那么 Check.php 我们看完了,继续返回 init.php 的最后一行会话处理程序选择,我们跟进 setSessionHandler。
可以看到第 100-109 行设置了 Session 的一些配置,第 111 行通过 get 方法获取 session.handler 的配置,由于 get 方法我们已经读过一遍了,所以在这里直接使用 var_dump 查看 Config::get('session.handler') 的值即可。
我们得到 Config::get('session.handler') = "files"
程序在 switch 语句中进入 default 字句,我们在判断循环时,可以在分支中加入 phpinfo();die();
等语句判断代码是否进入。代码第 121 行继续获取配置信息,使用 var_dump 查看后发现返回 int(1),进入 if 循环。第 122-130 行,程序创建会话目录,并设置 Session 配置。第 128 行判断指定的文件是否为目录,若不为目录则创建会话层级目录,跟进 create_session_dir 方法。
其代码逻辑为判断 $depth 参数是否小于 1,如果小于 1 则直接返回空。大于等于 1 时,递归创建 Session 目录。
至此,我们的 init.php 已经阅读完毕。接下来回到 start.php 继续读剩下的代码。
第 14 行进行了入口检测,若没有定义 IS_INDEX 常量,则直接退出。第 15 行启动内核,我们跟进 Kernel.php 的代码。
GG,可以看到代码已经被混淆处理。
通过在网上的解密脚本进行解密,但效果都不太理想,最终找到了一个在线网站(http://www.phpjm.cc/)
解密后的代码:
可以看到虽然字符串还是很长的一段随机字符,但是方法之间的调用耐下心来还是可以读的。在这里为了便于阅读,将这些很长的字符串全局替换为了一些比较短的字符,如 a、b、c等。
注意:程序对混淆后的 Kernel.php 进行了校验处理,所以不能删除和修改,否则程序无法运行
但是在我们跟进 Kernel.php 的 run 方法前,我们需要注意在 init.php 中程序注册了自动加载的函数,所以我们需要先去 Basic.php 中查看 autoLoad 方法是怎么实现的。
程序判断自动加载的类名的开头,然后将其文件路径赋值给 $class_file。之后又去判断类文件是否存在,不存在则报错,存在返回类文件名。接下来我们就可以开始阅读 Kernel.php 的代码了,首先我们看看 run 方法都做了些什么。
首先第 9 行定义了一个变量 $a,随后在第 12 行使用了 b 方法,我们跟进看看 b 方法。
第 404 行检查 LOCAL_ADDR 是否设置,未设置则获取 SERVER_ADDR 值。第 405-408 行负责将 IPv6 地址转换为 IPv4 格式。第 410 行获取 sn 授权码的配置,第二参数设置为了 true,所以结果将返回一个数组,我们去 get 方法 debug 查看一下最终的返回值。
最终返回了 281BE285D7,我们从 config.php 中也可以得到印证。
随后程序进入 if 循环,$aaa_user 获取授权用户手机,值为空。第 413-419 行获取 $yy、$ww、$zz 的值,进行了加密等一波字符串操作后得到了 $xx 变量。
第 421 行将 $xx 赋值给 LICENSE 常量,并在第 422 行 if 语句进行判断,这里跟进 get_http_host 函数。
可以看到 $noport 默认为 true,进入 if 判断,返回去端口的 Host 头。返回 422 行继续看,程序使用 filter_var 验证 Host头 是否为一个合法 IPv4 地址或 localhost 地址,满足则验证通过,返回空。
当 Host 头不为合法 IPv4 地址或 localhost 地址时,第 429 行 if 判断为真。由于系统默认不存在 $bbb 文件,因此程序直接返回报错信息。我们构造一个非法 Host 头来验证一下,程序确实返回了未匹配到本域名...报错。
那么 b 方法我们就已经读完了,接下来看看 c 方法是如何实现的。
首先第 339 行从配置文件中获取了 tpl_html_cache,配置默认值为 0,因此直接返回空。第 14 行将 e 方法的返回值赋值给 $d,我们跟进 e 方法。
第 24-35 行是可以防止编码不一致导致的漏洞,检测是否存在路径相关的参数,存在则将其转化为 UTF-8 编码。
第 36-57 行都是将当前访问的 url 路径信息赋值给 $d,随后 58-69 行对用户的 url 与允许的 url 字符进行匹配,匹配失败则进入 70 行的 if 判断,返回 404 响应码及错误信息。匹配成功则将 $d 赋值给常量 P ,返回 $d。
e 方法也读完我们继续看 f 方法,f 方法将 e 方法的返回值作为参数传入。
第 90 行获取 app_domain_bind 应用域名配置信息,程序默认值为 array(0),if 判断为 true。随后调用 get_http_host 方法获取当前的 Host 内容,由于不存在 $t[$s] 所以程序来到第 98 行,检测入口常量是否设置(这里为 home)。$r 的值为空,程序进入第 106 行,将入口文件值赋值给 $r,trim_slash 函数的作用是去除字符串两端斜线。最终返回 home/url 路径信息。
接下来再看 g 方法,g 方法也是将 f 方法的返回值作为参数传入。首先第 113 行从配置文件中获取了路由的相关信息,并赋值给 $u。随后 115 行判断传入的 $q 参数,这里判断为 false,不进入语句。第 119 行将路由信息进行遍历,匹配传入的 url 路径信息。匹配成功则将 url 路径信息中的路由键替换为路由值,最后将替换后的 url 路径信息返回。
i 方法也是将刚刚返回的 url 路径信息传入,我们来看一下具体实现。第 133 行从配置文件中读取了配置模块信息,注意这里第二个参数为 true,所以 $w 为一个数组(home,admin,api)。程序第 134-139 行将传入的 url 路径信息分割为数组 $r_array。第 140-155 行是程序的路由分配,根据数组元素的长度,将值分别赋值给 $h 数组元素。
注意这里的 m、c、f 并非是我们自定义的元素,而是 Kernel.php 文件中所原有的,我们可以回顾最开始被解密的文件。因此猜测 m 对应模块,c 对应控制器,f 对应方法。
继续阅读代码,第 156-167 行是为 m、c、f 进行初始化。第 168-171 行是判断模块是否存在于预定义的数组中,最后返回包含路由信息的数组 $h。
接下来我们再看看 k 方法,k 方法传入了包含程序路由信息的数组。第 178-180 行,定义了代表模块的常量 M,并根据模块定义了模块下的模型路径、控制器路径常量。第 181-195 行判断模块模板路径是否存在于当前模块的模板路径中,以及网站根路径是否存在于当前模块的模板路径中,将路径赋值给 APP_VIEW_PATH 视图模板常量。
第 194 行,判断控制器中是否存在.
字符。若存在则将其替换为/
字符,获取其路径中的文件名,并将首字母大写赋值给 $controller 变量。若不存在.
字符,则直接进行首字母大写并赋值给 $controller 变量。第 205 行获取完整的控制器路径,第 208-223 行根据模块及控制器来定义常量。
第 226 行定义控制器常量 C,第 227-235 行根据 REQUEST_URI 值来定义 URL 常量。第 227-246 行根据包含路由信息的变量 $a 元素数及 $dd 值,获取 GET 请求参数,最终返回控制器名称。
随后返回 Kernel.php 文件,我们还剩 l、m 方法没有看,下面看一下 l 方法。
第 251 行判断程序是否处于调试模式,debug 默认为 false,若处于调试模式会获取一些配置文件并检查关键文件是否存在。第 252-265 行判断,如果当前模块为 api 时,request 函数判断用户请求是否携带了 sid 参数,若存在则开启 Session,我们跟进看一下 request 函数。
第 532-536 行检查用户的请求类型(POST/GET),随后将其传入数组 $condition 中,并参数名及数组传入 filter 过滤函数中,我们再次跟进 filter。
代码首先在第 289 行判断传入的参数是否在数组键中,且是否拥有对应的变量描述文本。若满足条件则将其赋值给 $vartext 变量,不满足则将参数赋值给 $vartext。随后第 296-316 行,根据请求类型获取对应的参数值赋值给 $data。
随后的代码对数据进行了非常严格的类型检测、过滤、转义等操作,最终返回数据。
过滤代码使用了自定义的 preg_replace_r 函数,其作用是递归替换。
转义代码将数组、对象数据类型分别转化为字符串类型,随后又用了 htmlspecialchars 函数以及 addslashes 函数进行处理,htmlspecialchars 函数使用 ENT_QUOTES 参数,无法单引号绕过,同时指定了 UTF-8 编码。
读完后我们回到 Kernel.php 文件继续读 l 方法。程序第 261-265 行,若当前模块不为 api,则对用户的浏览器及操作系统进行检测。
分别跟进 checkBs 和 checkOs 方法。首先 checkBs 注释写的很明确,首先 105-106 行读取配置文件,获取黑/白名单。随后使用 get_user_bs 方法获取客户端浏览器类型,并根据黑/白名单进行拦截/放行。
我们跟进 get_user_bs 方法,首先判断 UA 信息是否存在,若存在则将其转化为小写,并赋值给 $user_agent 变量。其次检测参数 $bs 是否存在,若存在则判断 $user_agent 是否包含在 $bs 中,并返回 true/false。
随后即进行与已定义的浏览器进行固定检测,由于 $user_bs 的值为写死的,无法通过修改 UA 来进行日志注入等操作。
随后 get_user_os 函数原理相同,不再记录。随后 266-279 行都是在检测相应文件是否存在,若存在则进行包含。第 280-284 行,程序检查对应控制器文件是否存在,存在则进行实例化。
至此,l 方法我们也已经读完了,还剩下最后的 m 方法,我们跟进看看。方法 m 接受一个控制器路径参数,第 288-291 行都是将控制器路径信息进行赋值。第 292 行判断对应的控制器文件是否存在,若不存在则返回 404 错误。第 306-309 行,判断类中是否存在对应的类方法,若不存在则返回错误信息。
第 310-332 行对类进行了实例化操作,并判断对应方法在类中是否存在。若存在则判断是否存在同名方法,若存在则执行方法,将返回值赋值给 $nn。若不存在同名构造方法,则返回赋值实例化后的值。若类中不存在对应方法,判断类中是否存在 _empty 方法,若存在则执行获取返回赋值给 $nn,若不存在则返回报错信息。最后第 333-337 行判断返回的 $nn 是否为空,若不为空则输出 $nn。
至此,index.php 入口文件我们已经读完,接下来看看 admin.php 文件。
admin.php
可以看到,一切都是那么的熟悉,跟 index.php 的区别就是常量 URL_BIND 变为了 admin。
最后我们再来看一下最后一个入口文件 api.php
api.php
结构也是一样的,常量 URL_BIND 变为了 api。
漏洞
操作系统/浏览器黑白名单绕过
Kernel.php 的第 250-262 行,若当前模块不为 api 时,系统会检查用户的操作系统/浏览器是否在黑白名单中。
跟进 checkBs,105-106 行检查浏览器浏览器是否在配置文件的黑白名单中,在 111 行获取用户浏览器。
跟进 get_user_bs,系统直接通过 UA 头获取用户浏览器,进行黑白名单比对。
因此我们直接去到 config.php 中设置黑名单,禁止 firefox 浏览器访问。
设置后发现页面已经无法访问
通过 BurpSuite 将 UA 头进行修改,即可绕过限制,操作系统相关黑白名单同理。
存储型 XSS
过滤不严格导致的存储型 XSS
后台文章内容 -> 新闻内容 -> 新闻新增处存在存储型 XSS
首先黑盒测了一波,随便新建了一篇文章,并上传了一张图片附件。
抓包并观察数据包,发现 content 字段为我们刚刚提交的正文部分。
将其进行 URL 解码,发现内容中存在 HTML 标签。
因此我们可以尝试在 HTML 标签中加入一段 XSS 的 Payload,触发存储型 XSS
将修改后的 content 值重新放到 Burp 中重发,并将标题名改为了 hello2022,服务器返回 200 成功
接下来我们回到前台,进入新闻中心进行查看,成功触发 XSS
代码分析:
首先根据数据包路由信息,我们定位到代码位置:/apps/admin/controller/ContentController::add
可以看到,上来就是使用自定义的 post 方法获取一堆数据。
但是我们注意到,在这里的 post 方法大部分都是没有指定第 2 个参数的,而第 2 个参数的作用是限制数据类型。
而 add 方法也仅对部分变量做了规范化处理,并未对 content 进行相应处理,如过滤敏感标签、事件等。
随后将 content 变量放入了一个 data 数组中,调用 addContent 写入数据库,最终导致存储型 XSS。
由于逻辑相似,系统新闻内容、产品内容、案例内容、招聘内容等功能点均存在存储型 XSS漏洞。
上传 pdf 文件导致的存储型 XSS
在查看代码的配置文件 config.php 时,发现允许上传 pdf 文件,就想到了这个漏洞。
首先生成一个嵌有 js 脚本的 pdf 文件,然后正常作为附件上传即可,具体生成操作可参考百度。
前台访问文章附件触发:
反射型 XSS
看代码实在是看累了,就把网站丢进了扫描器。首先是扫到了一个 XSS ,我们验证一下结果。
好像还真的存在,类型的标签乱了。
我们随便敲个 ext_color=1,F12 看一下
可以看到图中的信息在 a 标签的 href 中被原样输出了,因此我们可以构造payload。
payload:http://127.0.0.1:8002/?product/&ext_color=1%22%3C/a%3E%3Cimg%20src=1%20onerror=alert(`1`)%3E
成功弹窗
SQL 注入
随后又扫到了一个 SQL 注入,我们验证一下结果。
恩?好像确实有东西
我们在 vscode 中搜索关键字 OR a.filename= 。发现第 559-566 行即拼接 SQL 语句的地方,$id 参数没有做过滤。
接下来我们最好能把拼接好的 SQL 语句打印出来,根据完整的语句构造注入。首先看到这个结构想到了 ThinkPHP,在 TP 中可以使用 fetchSql、buildSql 等方法打印 SQL 语句。
但尝试后发现行不通,使用 buildSql 后程序直接报错了,看来只能是用其他方法。
我们跟进最后的 find 方法,在第 992 行插入 var_dump 强行打印拼接后的 SQL 语句试一试。
成功返回了 SQL 语句,可以看到我们输入的12'"
在圆括号中包裹。
接下来我们就可以根据语句来构造特定语句了(这里要注意程序默认使用的是 sqlite 数据库)
Payload:http://127.0.0.1:8003/?1')/**/union/**/select/**/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,randomblob(1000000000)--
可以看到,根据右下角延时时间的对比,成功证明存在 SQL 注入漏洞。
END
文章到这里就结束了,出于个人原因这套 CMS 我并没有审计完成。后续如果时间允许的话,我也会继续以文章的形式分享出来。文章中所写的内容只是我个人在进行代码审计学习时所记录的笔记,希望能帮助到一些想要学习代码审计的同学。其中如果有错误的内容也希望大佬们能帮我指出,大家一起学习和进步。