PHP代码审计
AUDIT 启
一切的用户输入都是有害的,危险的数据进入了危险的函数便产生了漏洞。从这2句话,不难看出审计的关键在于用户输入和函数因此 大概分出2类审计方法。
- 跟踪用户的输入数据,检查代码运行的逻辑漏洞。
- 检索危险的函数,分析其参数是否被用户输入控制,如果可以,就很有可能有漏洞。
网站功能分析
-
注册功能
我们要根据需求列出服务器端需要接收的数据,假设有用户名、密码、邮箱、手机号等
-
注销功能
根据需要我们要回收令牌资源,保存用户操作以及状态(操作日志、当前用户等级等)。
-
发表评论功能
要判断用户评论的是不是空、是不是恶意代码、回复的哪篇文章
-
回复功能
回复给谁,是否能够无限回复? 有没有权限限制
-
下载文件功能
下载哪个文件,是否有下载权限
-
上传图片功能
上传的是哪种类型的文件?允许上传多大的?保存在哪里?是谁上传的?
AUDIT 工具
Seay源代码审计系统:C#语言开发的的,针对PHP的代码审计,Windows下运行。一键自动化白盒审计、代码调试、正则编码、自定义插件
输入输出
在PHP中可由用户输入的变量
- $_SERVER
- $_GET
- $_POST
- $_COOKIE
- $_REQUEST
- $_FILES
命令注入
函数 | |
---|---|
system | |
exec | |
passthru | |
shell_exec | |
popen | |
proc_open | |
pcntl_exec |
XSS - CSRF
函数 | |
---|---|
echo | |
printf | |
vprintf | |
<%=$test%> |
文件包含
函数 | |
---|---|
include | |
include_once | |
require | |
require_once | |
show_source | |
highlite_file | |
readfile | |
flie_get_contents | |
fopen |
<?php
highlight_file(__FILE__);
class emmm
{
public static function checkFile(&$page)
{
$whitelist = ["source"=>"source.php","hint"=>"hint.php"];
if (! isset($page) || !is_string($page)) {
echo "you can't see it";
return false;
}
if (in_array($page, $whitelist)) {
return true;
}
$_page = mb_substr(
$page,
0,
mb_strpos($page . '?', '?')
);
var_dump($_page);
if (in_array($_page, $whitelist)) {
return true;
}
$_page = urldecode($page);
$_page = mb_substr(
$_page,
0,
mb_strpos($_page . '?', '?')
);
var_dump($_page);
if (in_array($_page, $whitelist)) {
return true;
}
echo "you can't see it";
return false;
}
}
if (! empty($_REQUEST['file'])
&& is_string($_REQUEST['file'])
&& emmm::checkFile($_REQUEST['file'])
) {
include $_REQUEST['file'];
exit;
} else {
echo "<br><img src=\"https://i.loli.net/2018/11/01/5bdb0d93dc794.jpg\" />";
}
?>
包含上级目录的1.php文件
可以利用?截取hint.php,然后利用/使hint.php?成为一个不存在的目录,最后include利用…/…/跳转目录读取flag
file=hint.php?/…/ (当前目录)
代码注入
函数 | |
---|---|
eval | |
preg_replace /e | |
assert | |
call_user_func | |
call_user_func_array | |
create_function |
SQL注入
sql语句 | |
---|---|
insert | |
update | |
select | |
delete |
$sql="SELECT * FROM interest WHERE uname = '{$_POST['uname']}'";
echo $sql;
可以发现这是一个单引号注入
不过这里还定义了一个防止攻击函数,检测了常用的查询语句
function AttackFilter($StrKey,$StrValue,$ArrReq){
if (is_array($StrValue)){
//检测变量是否是数组
$StrValue=implode($StrValue);
//返回由数组元素组合成的字符串
}
if (preg_match("/".$ArrReq."/is",$StrValue)==1){
//匹配成功一次后就会停止匹配
print "水可载舟,亦可赛艇!";
exit();
}
}
$filter = "and|select|from|where|union|join|sleep|benchmark|,|\(|\)";
foreach($_POST as $key=>$valu){
//遍历用户输入数组
AttackFilter($key,$value,$filter);
}
此时就不能注入了,代码中要求查询到的密码和用户输入的密码相同
if($key['pwd'] == $_POST['pwd']) {
print "CTF{XXXXXX}";
在查询语句中加入group by pwd with roolup
就可以在输出结果底部多一条pwd置为NULL的记录
再在这个查询语句中加入limit 1 offset 2
,即取一行记录,从2行开始取
利用这一点,当我们输入的密码为NULL的时候,查询语句返回的记录中密码也为空就通过了判断。
SQL注入2
<?php
$user = $_GET[user];
$pass = md5($_GET[pass]);
$sql = "select pw from php where user='$user'";
echo $sql;
if (($row[pw]) && (!strcasecmp($pass, $row[pw]))) {
//如果 str1 小于 str2 返回 < 0; 如果 str1 大于 str2 返回 > 0;如果两者相等,返回 0。
echo "<p>Logged in! Key:************** </p>";
}
else {
echo("<p>Log in failure!</p>");
}
?>
这里需要sql查询返回的pw存在,且和输入的密码的md5相同。
首先令user为空,目的是使返回记录为0条,再利用sql联合查询,返回一个我们可以指定的值。
select pw from php where user='' union select "abcd" -- -
#这里的查询结果是一条pw为abcd的记录。
代码要求pw和输入密码的md5都形同,现在这2个参数都可以控制,构造合适的值即可输出flag。
password=123
password_md5=md5(123)=202cb962ac59075b964b07152d234b70
令pw=password_md5
最终的Poc
user=' union select "202cb962ac59075b964b07152d234b70" -- -
pass=123
文件管理
函数 | |
---|---|
copy | |
rmdir | |
unlink | |
delete | |
fwrite | |
chmod | |
fgetc | |
fgetcsv | |
fgets | |
fgetss | |
file | |
file_get_contents | |
fread | |
readfile | |
ftruncate | |
file_put_contents | |
fputcsv | |
fputs |
php://input 文件读取绕过
$a=$_GET['a'];
if(stripos($a,'.'))
{
echo 'Hahahahahaha';
return ;
}
$data = @file_get_contents($a,'r');
if($data=="1112 is a nice lab!")
{
require("flag.txt");
echo "flag";
}
这里通过get获取a值,在读取名字为a值的文件,要求文件内容为1112 is a nice lab!
,a的值不能包含.
,我们可以指定a值为php://input
,这时data的值可以通过POST传入。
文件上传
函数 | |
---|---|
move_uploaded_file |
变量覆盖
函数 | |
---|---|
extract | 将一个数组的key作为变量名,value作为值,赋值多个变量,同时对已经存在的变量可以起到覆盖更新原来内容的目的 |
一道简单的题目
<?php
$flag='flag.txt';
extract($_GET);
if(isset($shiyan))
{
$content=trim(file_get_contents($flag));
//将flag文件的内容赋值给content变量
if($shiyan==$content)
{
echo'ctf{xxx}';
}
else
{
echo'Oh.no';
}
}
?>
分析代码,可以知道shiyan这个变量是通过extract函数将用户输入$$_GET中的元素全部定义了一遍后得到的。这里并没有对GET参数做限制所以可以直接发送flag参数,利用extract函数重新赋值。
在这个代码中想要取得ctf值,需要使content变量和shiyan变量相等,其中content的值是flag文件的内容,通过对flag变量的覆盖,我们可以读取任意文件内容,但这里我们什么都不知道,因此可以使flag为一个不存在的文件,这样使得content变量为空,再传参shiyan变量为空,这样就通过了if判断。
Poc:
shiyna=&flag=nothatfile
多重加密
追踪用户输入找到request[‘token’]
if(isset($requset['token']))
//测试变量是否已经配置。若变量已存在则返回 true 值。其它情形返回 false 值。
{
$login = unserialize(gzuncompress(base64_decode($requset['token'])));
//gzuncompress:进行字符串解压缩
//unserialize: 将已序列化的字符串还原回 PHP 的值
$db = new db();
$row = $db->select('user=\''.mysql_real_escape_string($login['user']).'\'');
//mysql_real_escape_string() 函数转义 SQL 语句中使用的字符串中的特殊字符。
if($login['user'] === 'ichunqiu')
{
echo $flag;
}else if($row['pass'] !== $login['pass']){
echo 'unserialize injection!!';
}else{
echo "(╯‵□′)╯︵┴─┴ ";
}
}else{
header('Location: index.php?error=1');
}
可以看到只要$login数组的user值为ichunqiu即可输出key。
那么关键在于这一条语句
$login = unserialize(gzuncompress(base64_decode($requset['token'])));
//首先base64解密
//然后解压缩
//再反序列化
我们只要反过来操作就可以得到需要的token。
题目给出的代码不能直接执行,稍微改动一下测试
Session绕过哦噢
<?php
$flag = "flag";
session_start();
if (isset ($_GET['password'])) {
if ($_GET['password'] == $_SESSION['password'])
die ('Flag: '.$flag);
else
print '<p>Wrong guess.</p>';
}
mt_srand((microtime() ^ rand(1, 10000)) % rand(1, 10000) + rand(1, 10000));
?>
这个解题过程非常奇幻,测试的时候刚刚输入了password参数,手快就直接回车了,然后flag就出来了???
分析一下代码,Session的password在未登陆时为空,我们只要上传一个空的paasword即可绕过。
过滤绕过1
没有看到啥危险函数,使用正向分析追踪输入值
首先将函数拿出来单独分析,
function is_palindrome_number($number) {
$number = strval($number); //strval — 获取变量的字符串值
$i = 0;
$j = strlen($number) - 1; //strlen — 获取字符串长度
while($i < $j) {
if($number[$i] !== $number[$j]) {
return false;
}
$i++;
$j--;
}
return true;
}
函数判断number变量的整数部分是否首尾相同,形如121.
<?php
$info = "";
$req = [];
$flag="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
ini_set("display_error", false); //为一个配置选项设置值
error_reporting(0); //关闭所有PHP错误报告
if(!isset($_GET['number'])){
header("hint:26966dc52e85af40f59b4fe73d8c323a.txt"); //HTTP头显示hint 26966dc52e85af40f59b4fe73d8c323a.txt
die("have a fun!!"); //die — 等同于 exit()
}
可以看到这个页面需要GET参数number的存在,否则将会退出脚本
foreach([$_GET, $_POST] as $global_var) {
foreach($global_var as $key => $value) {
$value = trim($value); //trim — 去除字符串首尾处的空白字符(或者其他字符)
is_string($value) && $req[$key] = addslashes($value); // is_string — 检测变量是否是字符串,addslashes — 使用反斜线引用字符串
}
}
将🤺number变量赋值给req列表
if(is_numeric($_REQUEST['number'])) //is_numeric — 检测变量是否为数字或数字字符串
{
$info="sorry, you cann't input a number!";
}
number变量需要经过 is_number
函数检测
elseif($req['number']!=strval(intval($req['number']))) //intval — 获取变量的整数值
{
$info = "number must be equal to it's integer!! ";
}
number变量的整数部分如果和变量本身的值不相等,则会报错
else
{
$value1 = intval($req["number"]);
$value2 = intval(strrev($req["number"]));
if($value1!=$value2){
$info="no, this is not a palindrome number!";
}
当number变量的正向输出和逆向输出不相同时,会报错误信息。
else
{
if(is_palindrome_number($req["number"])){
$info = "nice! {$value1} is a palindrome number!";
}
else
{
$info=$flag;
}
}
}
echo $info;
number变量需要如果通过is_palindrome_number
函数的判断,就不会输出flag。
全篇代码分析之后发现number共经过4次检测。
is_numeric
使它为假$req['number']!=strval(intval($req['number']))
使它为假intval($req["number"])!= intval(strrev($req["number"]));
使它为假is_palindrome_number($req["number"])
使它为假
第一,二个检测可以通过%00的方式绕过
第三个需要num是回文数(例如131,12421),但第四个检测又需要他首尾不相同,测试发现当一个回文数字前面有+
号时第三个检测也可以通过即+12321==12321
判断为真。
而当数字前面加了+
后又使得第4个自定义函数检测为假,从而输出flag。
而第1,2个检测会忽略+
符号,所以不会影响结果
最终的Poc即为%00%2b111
过滤绕过2
if (ereg ("^[a-zA-Z0-9]+$", $_GET['password']) === FALSE)
{
echo '<p>You password must be alphanumeric</p>';
}
这里需要password不能有特殊符号,比较简单,直接%00截断即可
else if (strlen($_GET['password']) < 8 && $_GET['password'] > 9999999)
这里要求长度必须小于8,有要大于99999999,正常来讲是不可能的,这里通过科学计数法绕过1e5
if (strpos ($_GET['password'], '*-*') !== FALSE) //strpos — 查找字符串首次出现的位置
{
die('Flag: ' . $flag);
}
这里需要password包含*-*
,利用截断绕过
最终的Poc
password=1e9%00*-*
URL二次编码
<?php
if(eregi("hackerDJ",$_GET[id])) {
echo("<p>not allowed!</p>");
exit();
}
$_GET[id] = urldecode($_GET[id]);
if($_GET[id] == "hackerDJ")
{
echo "<p>Access granted!</p>";
echo "<p>flag: *****************} </p>";
}
?>
代码要求id参数不能等于hackerDJ,又需要id进行url解码后等于hackerDJ,我们只需要保证传入脚本的id参数等于hackerDJ的url编码值,但用户从浏览器传参时,会自动执行一次url解码,所以我们需要编码2次,才能使id传入脚本后还有一层url编码。
请求IP伪造
<?php
function GetIP(){
if(!empty($_SERVER["HTTP_CLIENT_IP"]))
$cip = $_SERVER["HTTP_CLIENT_IP"];
else if(!empty($_SERVER["HTTP_X_FORWARDED_FOR"]))
$cip = $_SERVER["HTTP_X_FORWARDED_FOR"];
else if(!empty($_SERVER["REMOTE_ADDR"]))
$cip = $_SERVER["REMOTE_ADDR"];
else
$cip = "0.0.0.0";
return $cip;
}
$GetIPs = GetIP();
if ($GetIPs=="1.1.1.1"){
echo "Great! Key is *********";
}
else{
echo "错误!你的IP不在访问列表之内!";
}
?>
|头部参数|
|–|–|
| CLIENT-IP |
|X-Forwarded-For|
|REMOTE-ADDR|
tip:前2个可以很简单的利用,但remote-addr的伪造比较麻烦,需要重新实现系统的 tcp协议栈,然后 自己改变自己的 ip
资料:https://yonghaowu.github.io/2018/11/23/get_reql_ip/
比较相等绕过
==
对比的时候会进行数据转换,0eXXXXXXXXXX 转成0,如果比较一个数字和字符串或者比较涉及到数字内容的字符串,则字符串会被转换为数值并且比较按照数值来进行
md5 | ||
---|---|---|
md5(‘240610708’) | == | md5(‘QNKCDZO’) |
md5(‘aabg7XSs’) | == | md5(‘aabC9RqS’) |
sha1 | ||
sha1(‘aaroZmOk’) | == | sha1(‘aaK1STfY’) |
sha1(‘aaO8zKZF’) | == | sha1(‘aa3OFF9m’) |
明文 | ||
‘0010e2’ | == | ‘1e3’ |
‘0x1234Ab’ | == | ‘1193131’ |
‘0xABCdef’ | == | ’ 0xABCdef’); |
弱类型整数大小比较
<?php
error_reporting(0);
$flag = "flag{test}";
$temp = $_GET['password'];
is_numeric($temp)?die("no numeric"):NULL;
if($temp>1336){
echo $flag;
}
?>
当一个整形和一个其他类型行比较的时候,会先把其他类型intval再比,例如12321a
在比较时会变成12321
。
十六进制绕过
<?php
error_reporting(0);
function noother_says_correct($temp)
{
$flag = 'flag{test}';
$one = ord('1'); //ord — 返回字符的 ASCII 码值
$nine = ord('9'); //ord — 返回字符的 ASCII 码值
$number = '3735929054';
for ($i = 0; $i < strlen($number); $i++)
{
// Disallow all the digits!
$digit = ord($temp{$i});
if ( ($digit >= $one) && ($digit <= $nine) )
{
// Aha, digit not allowed!
return "flase";
}
}
if($number == $temp)
return $flag;
}
$temp = $_GET['password'];
echo noother_says_correct($temp);
?>
这里的函数规定temp变量不允许出现1~9的内容,然后又要去和一个数字3735929054
相等,这个数字是十进制的,可以令temp等于这个数字的16进制数0xdeadc0de
,这样就temp就没有1~9的内容了,而10进制和16进制比较自然是相等的。
md5函数true注入绕过
$password = $_GET['password'];
$sql = "SELECT * FROM users WHERE password = '".md5($password,true)."'";
这里是一个单引号注入,难点在md5(pasword,true)
,这个函数当第二个参数值为true时,返回的内容从原来的32位md5变成了原始2进制流。
ffifdyop
动态函数
危险函数
strpos()
strpos()找的是字符串,那么传一个数组给它,strpos()出错返回null
sha1()
<?php
$flag = "flag";
if (isset($_GET['name']) and isset($_GET['password']))
{
if ($_GET['name'] == $_GET['password'])
echo '<p>Your password can not be your name!</p>';
else if (sha1($_GET['name']) === sha1($_GET['password']))
die('Flag: '.$flag);
else
echo '<p>Invalid password.</p>';
}
else
echo '<p>Login first!</p>';
?>
这里需要构造一对哈希值相等但明文不同的字符串,这里sha1函数无法处理数组,当处理数组时会报错返回False,这样就使得2个参数的哈希值“相等”,这里传参2个不同的数组即可绕过所有if。
strcmp()
<?php
$flag = "flag";
if (isset($_GET['a'])) {
if (strcmp($_GET['a'], $flag) == 0) //如果 str1 小于 str2 返回 < 0; 如果 str1大于 str2返回 > 0;如果两者相等,返回 0。
//比较两个字符串(区分大小写)
die('Flag: '.$flag);
else
print 'No';
}
?>
传入数据的类型是字符串类型,当传入的类型不是字符串类型 函数就会发生错误,显示报错信息后会return 0 ,即认为2个参数相等通过判断。
in_array()
查阅官方文档可以发现,in_array
有3个参数,如果没有设置第三个参数,那么将不会检查 needle 的类型是否和 haystack 中的相同。
题目来源:
https://github.com/bowu678/php_bugs
https://github.com/CHYbeta/Code-Audit-Challenges
https://github.com/hongriSec/PHP-Audit-Labs
https://chybeta.gitbooks.io/code-audit-challenges/content/