学习目标
1. 掌握PHP基础语法,能够编写简单的PHP程序。
2. 掌握PHP中弱类型、变量覆盖、文件包含、代码执行、反序列化漏洞相关概念,以及相应的利用方法。
PHP是非常受欢迎的一种编程语言。在CTF中,PHP语言的题目也经常出现,熟练掌握PHP语言,了解其中可能存在的漏洞是非常有必要的。本章会从PHP的基本语法出发,介绍PHP中可能存在的安全问题,帮助读者掌握相关知识点及题目的解题方法。
在学习PHP中可能存在的安全问题之前,需要先配置好PHP的运行环境,并且了解基本的PHP语法。
本小节介绍PHP的语法结构,读者可以自己在IDE中输入以下代码以加深理解。
(1)变量定义
运行结果如图2-1所示。
图2-1 该程序运行结果-1
(2)判断结构if
运行结果如图2-2所示。
图2-2 该程序运行结果-2
(3)函数定义与调用
运行结果如图2-3所示。
图2-3 该程序运行结果-3
本节将介绍PHP编码过程中的安全问题之一——弱类型。
在讨论PHP的弱类型特征之前,必须要明确什么是强类型和弱类型,或者更准确地说,应该叫强类型语言和弱类型语言。对于强类型语言,不同类型不能够相互转换,如字符串的1就是字符串1,不能和数字1相提并论;对于弱类型语言,不同数据类型能够相互转换,如字符串1和int 1可以相互转换。
例如下面这个例子:
尝试让一个字符串'1'直接和数字1相加。
当强类型语言(Python、Java等)遇到这个问题时会直接报错,如图2-4所示。
接下来仔细考虑一下为什么会报错呢?原因是字符串的1和数字类型的1相加时,这两者不是相同的数据类型,所以无法进行计算。
图2-4 在Python中的执行结果
当弱类型语言(PHP、JavaScript等)遇到这类问题时,就会“随机应变”——自动转换数据类型,以PHP为例,当字符串1和数字1要进行相加时,因为它们不是同一类型,势必要对其中一个值进行类型转换,如果是运算,PHP会把所有数据都转换成整型,如'1'→1、'1test' →1、'test'→0,全部转换成整型后再进行相加,如图2-5所示。
如果是拼接操作,PHP会把所有类型都转换成Str类型,然后进行字符串拼接,如图2-6和图2-7所示。
图2-5 PHP中执行结果-1
图2-6 PHP中执行结果-2
图2-7 PHP中执行结果-3
先来思考这样一个问题,'admin'和0在某些情况下是否会相等?在上一节讲解了PHP会自动进行类型转换,涉及的都是拼接之类的操作,那么如果是两者比较,又会进行怎样的类型转换?看下面这个程序:
由于使用的是两个等于号,所进行的判断是弱类型判断,只比较值,不比较类型,所以它首先会把admin转换成整型,但又因转换失败结果为0,这时就会比较成功从而返回True。
在上面这个转换中,1test转换成整型,由于字符串开头有个1,是可以被转换成整型1的,而test不能被转换于是舍弃,所以1test被转换为1。至此,比较成功返回True。
总结一下,一共有如下几种情况。
• 如果能转换成另一个比较的类型则进行转换,如'1test'和1比较,'1test'转换成整型。
• 如果是相同类型,并且都能转换成同一类型则进行转换,如'1'和'01'比较,都能转换成整型,那就全部转换为整型然后比较,最后就是1==01。
• 如果是相同类型,但不能转换成同一类型,如'a'和'1a',两者不能同时转换成整型,只能都当作字符串来比较。
看看下面这段代码:
这段代码的大意就是要输入两个值,然后对其进行MD5加密,之后用==(弱类型)去比较,如果两者“相等”则输出flag。
这就是典型的弱类型比较,只需要将两个符合如下条件的值输入即可。
1)MD5(值)计算后结果开头是0e。0e开头是让PHP把这段字符串认为是科学计数法字符串的先决条件。
2)0e后面全是数字。例如,0e123==0e234,0的 N 次方始终是0,所以弱类型比较可以相等。
回到开始的问题上,满足这两个条件(MD5后结果开头为0e,且后面全部是数字)的字符串如下:
md5(md5())后满足这两个条件的字符串也需要了解:
还需要了解MD4、SHA1中符合这两个条件(哈希计算后后结果开头为0e,且后面全部是数字)的字符串。
MD4符合这两个条件的字符串如下:
SHA1符合这两个条件的字符串:
当PHP开发者在编写代码时,很多时候为了方便会直接完全信任用户的输入,不做校验地赋值到自己程序的变量中。如果这时变量被传到了某些危险函数上,就会产生一些意想不到的后果。本节会介绍变量覆盖漏洞。
来看下面这样一段代码:
简单地解读一下,上面这部分代码定义了变量cmd,之后会被带入system执行这个变量中所记载的命令,如这里就是echo hello。但如果中间逻辑中$cmd可控,那么就可以操控$cmd,随心所欲地在目标机器上执行我们想要执行的命令了。这里$cmd被直接赋值,如果在system中多了一个能覆盖变量的函数,例如,能将$cmd的值改成whoami,就会造成下面system执行的是whoami这个命令,那么这就是一个变量覆盖漏洞。
变量覆盖漏洞的危害在于它能更改变量的值,一般都是配合其他函数/漏洞“打组合拳”。
在PHP中有如下几种变量覆盖的方式。
1) PHP语法导致的变量覆盖。
2) PHP函数导致的变量覆盖(extract、parse_str、mb_parse_str、import_request_variables)。
3) PHP配置项导致变量覆盖(register_globals: php.ini中的一个配置项,配置为true之后传入GET/POST参数都会被赋成变量)。
下面来看一下PHP语法导致的变量覆盖漏洞,PHP有种语法叫可变变量,可以动态设置和使用变量,代码如下:
上述代码就是可变变量,首先定义了$test,其值是abc,然后$$test这个语法先把$test解析成abc,继续解析就成了$abc='success',至此多了一个abc变量,造成变量覆盖。${$test}也是同理,会优先解析{}内的变量,然后${$test}==${abc}==$abc。
对于PHP函数导致的变量覆盖漏洞,来看下面这样一段代码:
extract可以接受三个参数。
• 第一个参数(必要):类型是数组,分为key和value。key是变量名,value是变量值,如果没有定义value值为NULL。
• 第二个参数:是一些可选的配置,如EXTR_OVERWRITE,假设变量已存在依旧覆盖,具体参数可以查看PHP文档。
• 第三个参数:前缀,仅在第二个参数为EXTR_PREFIX_SAME、EXTR_PREFIX_ALL、EXTR_PREFIX_INVALID或EXTR_PREFIX_IF_EXISTS时生效,自动为变量加上前缀。
再来看下面这段parse_str的代码:
parse_str函数可以接受两个参数。
• 第一个参数(必要):类型是string,格式为test=b&b=1,变量名=变量值,&是分割符。
• 第二个参数:类型是array数组,加上这个选项不会生成新变量,可以理解为,变量都存放进指定的数组。
注意,此函数会自动URL解码。例如,我们想把$test覆盖成$,而$刚好是变量分隔符,这里可以使用%26 (URL编码转义)。
mb_parse_str函数同理。
上一小节还提到了import_request_variables,这个函数在PHP 5.4就已经被淘汰了,基本用不上。
对于PHP配置项导致的变量覆盖,需要在php. ini中设置register_globals=On。需要注意,此配置项PHP 5.3.0起废弃,并且从PHP 5.4.0起就被移除了。这个配置项顾名思义就是自动把请求的GET/POST参数注册为变量。例如,请求时携带GET参数a=1,那么这时就相当于执行了$a=1。
看看案例[BJDCTF2020]Mark loves cat,打开之后页面如图2-8所示。
图2-8 Mark loves cat题目截图-1
访问靶机地址,那么第一件事就是信息搜集。找到漏洞点,查看源代码,搜索.php没有什么发现,接着扫描敏感文件。当访问/.git/时,返回403 Forbidden,说明存在/.git/信息泄露,如图2-9所示。
图2-9 Mark loves cat题目截图-2
使用git_extract获取源码,发现只有两个php文件:flag.php和index.php。
下面是flag.php的内容:
index.php的内容如下:
代码中if较多。遇到这种题目,就直接看关键字。最后有echo$flag,并且前面有$$,经典的变量覆盖问题。由于$flag在flag.php中定义了,并且在开头就包含了文件,是否可以利用变量覆盖漏洞把$yds的值覆盖成$flag?当前可以了。
直接使用GET传递yds=flag,遇到以下的变量覆盖时:
就成了$yds=$flag,成功把$flag赋值给了$yds,然后不传递flag参数,让程序退出,输出$yds,拿到flag。
在PHP开发过程中,为了缩减单个PHP文件的代码行数,也为了提升代码的复用性,通常会将这些代码拆分编写,当最后要运行时再用相应的语法互相包含。而一旦这些语法使用不恰当,就会存在安全漏洞。
在PHP中,从一个PHP文件中包含其他PHP文件的语法或者说函数有如下几个:
include、include_once、require、require_once。
使用的例子如下,以下例子均是包含b.php:
需要厘清两个不同:
1)有无once,有once的情况下只会包含一次,即便之后再调用相同的语句也只会包含一次。
2)关于require和include。当include包含文件遇到一些错误,程序还可以继续往下执行,输出“执行完成”;当require包含文件遇到错误时,程序就不会继续往下执行了。
再回到最基本的概念上,文件包含,顾名思义就是把其他文件包含进来,在开发中经常用到,例如,项目结构如下:
在上面的项目中,index.php和search.php都要进行SQL查询,难道每次都要写重复代码连接数据库进行查询吗?
当然不必,这完全可以实现复用,将数据库相关的操作都写入database.php,然后用include('config/database.php')包含进来,调用其中所定义的逻辑即可,这样就是文件包含。
知道了什么是文件包含,大家再想想文件包含必须是php扩展名吗?答案:并不是。PHP的文件包含会先读取文件,如果内容是PHP代码,那就直接解析成PHP代码包含进来,与扩展名无关,如果不是PHP代码,它就会原样输出。
文件包含漏洞的危害与这两种不同的处理有关。
• 第一种,攻击者通过上传恶意的文件到服务器上,文件内容是PHP代码,包含后执行任意的PHP代码。
• 第二种,攻击者通过文件包含直接包含非PHP代码的文件,如/etc/passwd,直接输出,变成一个任意文件读取漏洞。
知道了什么是文件包含,接下来就学习下文件包含漏洞及常见的利用。本地文件包含,顾名思义,包含的文件必须存储在本地。来看下面这样一段代码:
观察上面的代码,大家可以思考下有何区别?
• 第1行代码直接将file参数的值传入include,没有任何限制。
• 第2行代码会在file参数的值前面拼接上一个绝对路径。
这两者的区别在于:如果开头没限制,可以直接通过绝对路径或者协议去利用文件包含,利用方式更多;而开头受到限制,如我们输入/flag想读取根目录的flag,拼接后就会成为/var/www/html/flag,从而改变了原本的意义,这时候就要用../../跨目录,如/var/www/html/../../../flag,这样就能读取到根目录的flag。
在做题时,首先要猜测后端源码是怎样的写法(没限制、前面限制、限制后扩展名)。例如,存在一个/?file=admin.php的接口,看到admin.php,我们就要猜测后端源码写法可能如下:
之前有讨论过两种代码的区别,再来看看探测方法的异同。
• 无限制:直接输入/etc/passwd,如果有passwd的内容(一般为“root: x:”开头)那说明后端没有任何操作,直接带入的函数。
• 前面限制(路径拼接):如果上一步没有反应,那说明参数开头或者PHP配置可能受到了限制,可以尝试.. /.. /.. /.. /etc/passwd跨目录后读取文件。
还有一种限制是限制扩展名,如只能包含.php文件,后文会和文件包含伪协议一起讲解。
知道如何判断文件包含后,我们来试试将它变成一个“任意文件读取漏洞”进行信息搜集。在CTF中有价值的文件路径通常有如下这些:
以上就是“任意文件读取漏洞”的利用方式,那么如何包含文件,进而执行PHP代码Getshell?
1)Web一般都会有文件上传等功能,只要在图片中插入我们的PHP代码,然后包含该图片即可,如图2-10所示。
图2-10 图片文件包含利用
2)可以利用中间件日志文件来助攻。一般中间件默认都会开启日志记录,当请求http://127.0.0.1:8080/1.php?file=<?php phpinfo();?>这个地址时,这个请求就会被中间件保存在自己的日志中,如图2-11所示。
图2-11 中间件日志
可以通过文件包含来包含日志文件,从而解析日志中存在的“恶意代码”,如图2-12所示。
图2-12 中间件日志文件包含
3)通过SSH日志文件包含:尝试通过命令ssh'<?php phpinfo();?>'@HOST去连接到目标机器,我们的代码就会被当成用户名存放在/var/log/auth.log中(如图2-13所示),然后文件包含即可,如图2-14所示。
图2-13 SSH日志插入恶意用户名
图2-14 包含SSH日志
上一节介绍了PHP本地文件包含,既然有本地,那么也就有远程文件包含了。
远程文件包含先根据输入的URL访问到远程资源,然后再把内容返回。进行包含时,远程文件包含需要PHP配置项中allow_url_include=On,否则无法利用,大家可以修改/etc/php/7.0/apache2/php.ini(以Kali Linux为例)中的配置项,如图2-15所示。
图2-15 修改PHP配置
远程文件包含可以包含远程服务器的文件。之前我们的利用都需要在本地存在文件,然后包含,如果开启allow_url_include后,可以直接包含远程服务器的文件执行代码。
远程文件包含一般有HTTP、FTP、SMB、Webdav等各种协议,限于篇幅,这里简单介绍一下HTTP和FTP的文件包含。
1)HTTP远程文件包含:在远程HTTP服务器上,写一个文件,内容是PHP代码;在靶机上输入http://IP/1,靶机就会通过HTTP协议去包含指定的文件,如图2-16所示。
图2-16 HTTP远程文件包含
2)FTP远程文件包含:与HTTP远程文件包含同理,但不同的是这里使用的是FTP协议进行文件包含。这里我们用Python启动一个服务器:
然后在tmp目录下编写PHP代码的文件,用FTP远程文件包含此文件,如图2-17所示。
图2-17 FTP远程文件包含
请记住,上面提到的这些内容仅仅是文件包含漏洞的开始,在下一小节中会进一步介绍。
文件包含中可以使用非常多的协议,例如,常见的HTTP、FTP,或者PHP特有的php://filter等。接下来让我们一一探索这些协议在文件包含漏洞中的利用方法。
之前介绍了文件包含无限制及开头受到限制两种情况,还提到了扩展名限制这种情况,在这种情况下有什么利用方式?先来看看下面这段代码:
在做题时常常会遇到这种情况,看到file=index这种类似的URL时就应该想到这里可能存在文件包含漏洞,且猜测后端源码为index拼接.php。此时我们就可以用一个特殊的协议来读取源码,即php://filter/。
php://filter/是元封装器,用于在数据流打开时筛选过滤应用,通俗来讲就是把读取到文件的内容进行一些处理。
之前介绍过:文件包含会先读取文件内容然后包含进来解析。那么如果遇到PHP文件呢?难道只能包含PHP文件执行吗?有什么办法可以获取到它的源码,接下来做个小实验。
这里我们写两个文件:一个文件为index.php,用于文件包含;一个文件为api.php,里面是php代码。当输入以下payload后,可成功将api.php的内容Base64编码后输出,如图2-18所示。
图2-18 文件包含读取源码
接下来分析一下这个payload:
• php://filter表示用PHP过滤器。
• /read后面跟上你要使用的过滤器,如convert.Base64-encode就是调用了Base64过滤器对文件内容进行编码。
• /resource后面是输入的文件。
包含时,它会先读取文件内容,然后经过过滤器对其进行Base64编码,最后包含进来。由于Base64编码后不是PHP代码,而是一些普通字符,所以会直接输出。之后我们可以调用PHP的Base64或者在线工具解密一下获取到的源码:
除了Base64过滤器,还有其他的过滤器,例如:
如果Base64被过滤了,只需要用其他协议打乱文件中的PHP标签。因为PHP依据<?php?>标签决定是否解析,假如我们使用convert.iconv.utf-8.utf-7把PHP标签打乱,使其无法正常解析,其中的代码就会被当作字符串直接输出。
上面就是通过伪协议读取PHP文件的方法了。再来思考这样一种情况,假设扩展名限制了只能包含php结尾的文件,除了读取源码还能RCE吗?当然是可以的,只是有一些条件,如开头不能受限制,并且你能上传一些特殊文件。以下的这些协议都是对压缩包进行解析,能获取到压缩包内的文件,并且对压缩包的文件名无要求,只要文件结构是压缩包即可。各个协议利用如下。
1) phar:///tmp/zip.jpg/1.php (获取压缩包中的1.php文件然后包含)。
• phar://表示协议格式。
• /tmp/zip.jpg表示要解析的压缩包,与扩展名无关。
• /1.php表示压缩包中的文件。
2)zip:///tmp/zip.jpg#1.php,与phar相同,但是获取压缩包下文件的分隔符为#。
以上的利用方式常用于限制扩展名,并且能上传ZIP文件的情况下。例如,有一个文件上传只能是JPG扩展名,并且存在一个文件包含点,只能包含abc扩展名的文件,由于文件上传扩展名限制,且无法绕过,那么我们就可以上传一个名为1.jpg的压缩包,压缩包中放一个1.abc内容的PHP代码,通过phar或者ZIP协议去包含压缩包内的1.abc即可RCE(Remote Command/Code Execude)。
如果不能上传文件,又该如何RCE?需要在php. ini中设置url_allow_include=On,开启远程文件包含这个配置项,如图2-19所示。
图2-19 文件包含读取源码
当我们开启上面这个配置项之后,就可以使用php://input这个地址使PHP包含我们POST上去的内容了。php://input是PHP的输入流,获取POST的所有数据,可以理解为另一种获取POST值的方式,效果如图2-20所示。
可以看到,通过POST输入的所有值都会传入include,那么如果我们传入恶意的PHP代码,也会传入include,从而造成RCE,如图2-21所示。
图2-20 PHP输入流效果
图2-21 PHP包含输入流执行phpinfo
除了php://input这种输入流外,还可以使用data://来构造我们想要的数据进行include。data协议是个传入数据的协议,和input://类似。例如,data://text/plain,hello world,我们可以通过data协议后面传入明文或者Base64编码的数据去传入字符串,效果如图2-22所示。
同样,把hello world替换成<?php phpinfo();?>,data协议解析成字符串后,传入include,同样能RCE。如果有关键字过滤,如过滤php、system等字符串,它们明文会被拦截,可以使用Base64编码来绕过:data://text/plain;Base64,PD9waHAgcGhwaW5mbygpOyA/Pg==。
图2-22 PHP包含data伪协议内容
让我们来看看“[BJDCTF2020] ZJCTF,不过如此”这道题。打开这个题,直接给出了源码,如图2-23所示。
图2-23 “ZJCTF,不过如此”题-1
通过审计,我们传入的text需要等于I have a dream,并且file不能包含flag字符,这一步是为了防止包含/flag直接拿到flag。并且下面有个提示next.php,那么需要用伪协议去拿到next.php的源码。
思考一下该用什么方法去解题,text有两种方法,一种是data伪协议,一种是php://input,这里直接使用php://input传入I have a dream。而对于要读取的目标文件file参数,则可以传入php://filter/read=convert.Base64-encode/resource=next.php,得到的截图如图2-24所示。
图2-24 “ZJCTF,不过如此”题-2
将Base64编码后的源码解码,得到next.php源码。继续往下解题就要涉及其他的知识点了,暂且不提,主要是第一步文件包含需要掌握。
PHP是一种很灵活的编程语言,开发者可以在代码中很容易地插入一段字符串,使其被作为PHP代码执行。很多时候这种特性会给开发带来极大便利,如模板渲染等。但很多时候,如果攻击者能够控制这段字符串的内容,那么执行的东西可就很有意思了。本节会针对PHP代码执行漏洞进行讲解。
如果有开发经验,或者是经常在CTF比赛中做Web题,下面这种样式的代码想必也见得不少:
最后五行是等效的代码。如果把这个代码部署在我们本地的PHP服务器上进行访问的话,会发现给method这个GET参数传入method1即可调用执行method1,输出“Here is method1”,同理,输入method2和method3就会输出各自对应函数的运行结果。我们在编写代码时并不需要做大量的判断,就可以在代码运行时通过传入相应的参数即可调用对应的函数,十分方便,如图2-25所示。
图2-25 动态调用函数
但这种便利存在的同时,其中存在的危险也是不容忽视的。例如,“eval("(new TestController())->".$_GET ['method']."();");”这段代码,我们如果写一句话木马的话,“<?php eval($_POST ['a']);”(这句话的本质就是允许你本地传输一段PHP代码过来,它进行运行然后返回结果),它把输入的method参数也进行了一个拼接执行,然后把结果展示了出来。两者都是把输入当成代码或者代码的一部分去执行了。
那么把一段PHP代码给拼接进去,并且让它能被执行,会有什么结果?
以第一段代码为例,如图2-26所示。
图2-26 eval动态调用函数
进行/2.php?method=method1();phpinfo的访问,如图2-27所示。
图2-27 eval动态调用函数,插入恶意代码
我们会看到除了method1的结果展示出来了以外,phpinfo这个函数的运行结果也被展示出来了。
从代码执行的逻辑上来理解,原本正常访问/?method=method1,eval接收到参数,拼接出来的代码是“(new TestController())->method1();”,正常调用method1。但如果传入的参数是method1();phpinfo,那么拼接出来的代码就是“(new TestController())->method1(); phpinfo();”,运行的结果就很有意思了,除了method1被调用了以外,phpinfo也被调用执行了。我们利用这里存在的代码执行漏洞成功进行了一次利用。
总结来说,代码执行漏洞就是在代码中若存在eval、assert等能将所接收的参数作为代码去执行,并且拼接的内容可被访问者控制,也就是把传入的参数给拼接进去了,造成了额外的代码执行,也就造成了代码执行漏洞。
了解完最基本的定义,那么再来了解一下存在这些漏洞的函数吧。
正如我们上面看到的,最常见的可能会存在代码执行的函数就是eval和assert。
(1) eval
eval会直接把输入的字符串作为PHP代码去执行。要注意代码结尾需要用;号结束,如图2-28所示。
图2-28 PHP手册中eval函数页
(2)assert
assert会直接把输入的字符作为PHP代码去执行。代码结尾不需要用;号结束,如图2-29所示。
图2-29 PHP手册中assert函数页
(3) call_user_func (见图2-30)
call_user_func第一个参数是你要调用的函数名字,也可以是一个数组,前面是对象名或者类名,后面是类中的方法。
图2-30 PHP手册中call_user_func函数页
像上面我们的那个例子里:
这样输入,得到参数为['TestController','method1'],而如果只是单纯地访问/?req=phpinfo,调用的就是phpinfo。
(4) create_function (见图2-31)
图2-31 PHP手册中create_function函数页
可以用这个函数来创建一个函数,前面是参数,后面是代码。就像上面那个例子:
对于参数我们传入method1,则会调用method1。但如果像上面那个eval的例子一样插入“method1();phpinfo”的话,除了会调用method1之外,也会调用phpinfo。
(5) array_walk、array_map、array_filter (见图2-32~图2-34)
图2-32 PHP手册中array_walk函数页
图2-33 PHP手册中array_map函数页
图2-34 PHP手册中array_filter函数页
这是三个操作数组的函数,我们要利用的是它们的第一/二个参数callback,这里允许我们传入一个函数名,这些函数会将第一/二个参数中数组的每个元素作为参数传入调用函数中。
比如:
则等效于:
(6)$_GET ['method']()
PHP比较灵活。在代码中函数名可以接收一个参数,当你给method这个GET参数传入的不再是?method[]=TestController&method[]=method2,而是?method=phpinfo的话,结果就很明显,是调用了phpinfo。
其实除了以上所提到的函数以外,还有usort、ob_start等执行函数,PHP奇妙无比,期待各位读者自己细细体会。
来看看这一个竞赛题,这是“2021年虎符网络安全大赛Unsetme”题。
打开靶机,如图2-35所示。
图2-35 Unsetme题-1
结合代码和上网搜索可以得出这是F3框架。
把代码复制到本地,打开报错,查看代码,可以一直运行到lib/base.php的530行,如图2-36所示。
图2-36 Unsetme题-2
尝试输出,可以看到unset的代码将a拼接了进来,如图2-37所示。
图2-37 Unsetme题-3
那么就想办法闭合代码,使其合法即可,如图2-38所示。
图2-38 Unsetme题-4
然后就可以读取flag了,如图2-39所示。
图2-39 Unsetme题-5
通过分析,找到了这组代码中存在的代码执行漏洞,插入了我们想要执行的代码。
PHP是一种面向对象的语言,类和对象是其中非常重要的概念,在这些信息加工储存的过程中就涉及形式上的变化,如对象如何变成一串字符串存储起来,以及如何从这些字符串还原成对象。在这个过程中如果数据被篡改,又会造成什么样的后果?本节将介绍PHP反序列化漏洞。
在学习PHP的序列化与反序列化之前,我们得先了解一下面向对象的基本定义和一些基本名词。
• 类:类(Class)在面向对象编程中是一种面向对象计算机编程语言的构造,是创建对象的蓝图,描述了所创建对象共同的特性和方法。
• 对象:在软件系统中,对象具有唯一的标识符,对象包括属性(Properties)和方法(Methods),属性就是需要记忆的信息,方法就是对象能够提供的服务。在面向对象(Object Oriented)的软件中,对象(Object)是某一个类的实例(Instance)。
• 方法:方法指的是类别(即类方法、静态方法或工厂方法)、或者是对象(即实例方法)两者其中之一的一种子程序。如同过程化编程语言的程序,一个方法通常以一系列的语句组成,并以之完成一个动作。其可以借由输入一组参数制订所需的动作,且一部分的方法可能会有输出值(即返回值)。
• 属性:属性就是描述该对象所蕴含的信息。比如,假设你是一个对象,那么你的属性会具有姓名、性别、年龄等。
综上所述,就是可以从类实例化成一个对象,对象中可以具有方法和属性。其中公开属性在类内、类外都可以访问,保护属性只有自身和自己的子类可以访问,私有属性只有自己可以访问了。
关于类的一些基础概念我们介绍完毕,那么接下来需要思考一个问题,就是如何存储和与其他程序交换这些类所实例化出来的对象。我们在数据库中想存储一个字符串非常简单,直接用text类型进行保存即可。但如果想像存储字符串这样手工存储一个对象就比较麻烦,需要把所有属性的值全部取出,依次保存。如果这里面的属性又是一个对象,那就得继续层层递归下去继续取出保存。需要还原时更麻烦,需要取出再层层还原。所幸PHP提供了两个函数能完成这一切,下面就来介绍一下这两个函数和相关的概念。
第一个函数叫serialize,它的作用主要是帮助我们把一个对象转换成一段文本,而转换的这个过程就叫序列化,如图2-40所示。
图2-40 PHP手册中serialize函数页
一般来说,它接收一个值作为参数,然后返回一个字符串来代表这串值。我们用下面这段程序来做一个小实验。这个实验实例化了一个类Person的对象p,然后把这个对象传给serialize进行调用,再尝试打印调用的返回值。代码如下:
运行上述代码,可以看到输出了下面这样的一段字符串,如图2-41所示。
图2-41 serialize运行结果
像上面这样,形如0:7:…这样的字符串就是代表p这个类对象的字符串,刚才我们就进行了一次序列化操作。
注意,图2-41中有几个方框字符,这些字符并非本身是方框,而是因为它们所代表的是保护和私有的属性,PHP需要用一些特殊的字符来包裹对应的属性名,用来存储它们的属性。为了更好地进行演示,这里我们给它进行一下URL编码,用于之后的演示。下面我们给输出结果包裹urlencode()来看一看,代码如图2-42所示。
图2-42 serialize包上urlencode
运行上述代码,可以看到编码之后的序列化字符串,结果如图2-43所示。
图2-43 serialize包上urlencode运行结果
再来看能把字符串转换回对象的函数unserialize,我们给它传入一个字符串,调用它之后即可得到这个字符串所对应的对象。这个把字符串转换回对象的过程就叫反序列化,如图2-44所示。
看下面这样一个例子,我们把刚刚获得的字符串传入unserialize,然后把获取到的返回值也就是这个字符串对应的对象保存到p这个变量中,再尝试调用这个对象的方法get_age,获取它的age属性进行输出。代码如下:
图2-44 PHP手册中unserialize函数页
运行上述代码,可以看到成功输出了对象的属性age的值,如图2-45所示。这证明我们成功进行了一次反序列化操作。
图2-45 运行结果
关于对象和对象的序列化、反序列化的相关知识介绍完毕,接下来就该看看其中有什么薄弱点可供利用了。
在构造一个对象时,如果想要给对象动态设置一些初始属性,就需要在类的定义中添加一个叫构造方法的方法,在PHP中这个方法叫___construct。
来看下面这个例子,我们给Person类定义了一个构造方法,接受三个参数,把这三个参数的值分别复制给自己的三个成员变量,再尝试输出age的值。代码如下:
运行上述代码,如图2-46所示,可以看到age的值是25,也就是我们传给构造方法的值。
图2-46 运行结果
同样,在程序结束运行,对象被销毁时也可以定义一个在此时会被调用的方法,叫析构方法。在PHP中这个方法叫___destruct。代码如下:
运行上述代码,如图2-47所示,可以看到程序在输出完age的值之后,输出“我被销毁啦~”这句话,说明___destruct这个方法在程序运行结束,对象被销毁时被调用了。
图2-47 运行结果
除了这些方法,还有没有其他的方法?我们再来看___wakeup这个方法,这个方法在对象被反序列化时会被调用。来看下面这个例子,在这个例子中我们定义了一个___wakeup方法,尝试对对象进行序列化然后反序列化。
运行上述代码,如图2-48所示,可以看到“我正在被反序列化~”这句话是在“序列化完成,正在反序列化”之后被输出的,证明___wakeup这个方法是在反序列化时被调用的。
图2-48 运行结果
上面介绍了几种特殊的方法定义,除此之外在PHP的类定义中还有类似___sleep(序列化时调用,用于指定那些属性会被序列化保存下来)、___call(当调用不存在的类方法时就会调用到它)、___get(当尝试获取不存在的类属性时会调用它)等,篇幅有限,读者可自行了解。在PHP中,这类方法统称魔术方法。
了解了那么多魔术方法,那么如何使用它们来进行进一步的利用?不知读者有没有设想过这样一种情况,如果在___destruct等这种会在对象生命周期(创建、销毁等)被调用的方法中,存在一些只要参数可控就会造成破坏的函数调用(如后文会提到的代码执行漏洞中的eval和assert函数,以及命令执行漏洞中会提到的system等函数),并且这些函数的参数来自类的属性,那么我们只要想办法反序列化出来一个对象,对象中的属性我们可控,那么就只要程序运行结束,对象被销毁,那么我们就可以操控这些函数的调用,让它执行我们想要执行的代码或者命令了。
来看下面这样一个例子。在这个例子中我们定义了一个魔术方法___destruct,其中调用system输出形如“hello,A”这样的字符串。之后我们定义了一个类对象p,将其name属性设置为A;whoami。代码如下:
运行上述代码,可以看到共有两行输出,第一行是“hello,A”。第二行则是whoami的执行结果jinzhao,输出了当前系统的用户。证明我们通过控制属性,控制了___destruct这个析构函数的执行结果。
那么当我们构造了这样一个对象,再把它序列化到我们的目标上,再反序列化,就可以达到和我们本机上执行效果类似的效果了。来看下面这个例子。首先将这个对象序列化并做URL编码。代码如下:
运行上述代码,如图2-49所示,获得一串序列化之后的字符串。
图2-49 运行结果
然后再把这串字符串反序列化。代码如下:
运行上述代码,如图2-50所示,可以看到一样的效果。拿到了whoami执行的结果。
图2-50 运行结果
通过上面这个例子,我们了解到了在反序列化中,通过控制属性,可以让有调用到这个属性的魔术方法朝着我们想要的方向去执行。
那么问题又来了,如何在目标上反序列化出这样一个对象?有两种方式。
• 直接控制unserialize函数的参数值,很少见,但只要能控制,就可以随心所欲反序列化出我们想要的对象了,如图2-51所示。
• 通过文件操作函数来进行反序列化的操作,如file_get_contents、file_exist等函数。倘若它们的参数可控,不仅可以读取到任意文件的内容,同时也可以进一步扩展,达到RCE等目的,如图2-52所示。
图2-51 直接unserialize
图2-52 可控的文件操作函数
为什么读取一个文件也会造成反序列化?我们来看PHP手册中对于PHAR这种压缩文件的一段描述,如图2-53所示。
图2-53 PHAR文件结构
简单来说,就是可以在这个压缩文件中存储一个对象,当这个压缩文件被PHP读取时,就会反序列化这个对象,内存中就存在这个对象了。那么如果这个对象有析构函数或者是其他的魔术方法定义,则在特定的时机(如程序运行结束时)就会被调用,可控的对象+可以利用的魔术方法就构成了我们对于反序列化漏洞的利用了。
下面我们利用PHP代码来尝试生成一个带有对象的PHAR文件。代码如下:
运行上述代码,读者会在同级目录下看到一个phar.phar文件,如图2-54所示。
图2-54 已生成PHAR文件
然后再使用下面的PHP代码去读取这个文件:
运行上述代码,如图2-55所示,可以看到成功调用了Person类对象的析构方法,并像之前的例子一样,控制该对象的属性,等析构方法被调用时执行命令。
图2-55 运行结果
通过上述例子,我们基本了解了反序列化漏洞的概念和基本的利用方法。下面来看个比赛中的题目,加深理解。
来看看“2019强网杯的UPLOAD”这个竞赛题,先打开靶机看一看,如图2-56所示。
图2-56 UPLOAD题-1
看起来是个登录和注册页面,那么就先注册然后登录试试吧,如图2-57所示。
登录之后看到了如图2-58所示的页面,测了一下只能上传能被正常查看的png。
图2-57 UPLOAD题-2
图2-58 UPLOAD题-3
跳转到了一个新的页面,这个页面似乎没有任何实际功能了。然后可以看到我们的图片是被正确上传到服务器上的/upload/da5703ef349c8b4ca65880a05514ff89/目录下了,如图2-59所示。
图2-59 UPLOAD题-4
然后我们来扫描敏感文件,发现/www.tar.gz下有内容,下载下来解压看看,发现是用ThinkPHP 5框架写的,如图2-60所示。
图2-60 UPLOAD题-5
而且其有.idea目录,我们将其导入PHPStorm。发现其在application/web/controller/Register.php和application/web/controller/Index.php下有两个断点,估计是Hint。
application/web/controller/Register.php下的情况如2-61所示。
图2-61 UPLOAD题-6
application/web/controller/Index.php下的情况如图2-62所示。
经查看发现这两个点的流程大概如下。
• application/web/controller/Index.php中的断点流程:首先访问大部分页面(如index)都会调用login_check方法。该方法会先将传入的用户Profile反序列化,而后到数据库中检查相关信息是否一致。
图2-62 UPLOAD题-7
• application/web/controller/Register. php中的断点流程:Register的析构方法,估计是想判断是否注册,没注册的调用check也就是Index的index方法,即跳到主页。
接下来再来审一下其他代码,发现上传图片的主要逻辑思路在application/web/controller/Profile.php中,如图2-63所示。
图2-63 UPLOAD题-8
先检查是否登录,然后判断是否有文件,获取扩展名,解析图片判断是否为正常图片,再从临时文件复制到目标路径。
而Profile有_call和_get两个魔术方法,分别编写了在调用不可调用方法和不可调用成员变量时怎么做,如图2-64所示。
图2-64 UPLOAD题-9
别忘了前面我们有反序列化和析构函数的调用,结合这三个地方就可以操控Profile中的参数,控制其中的upload_img方法,这样就能任意更改文件名,让其为我们所用了。
首先用蚁剑生成个Webshell,再用hex编辑器构造个“图片马”,重新注册个新号上传上去。如图2-65~图2-67所示。
图2-65 UPLOAD题-10
图2-66 UPLOAD题-11
图2-67 UPLOAD题-12
然后构造一个Profile和Register类,命名空间app\ web\ controller (要不然反序列化会出错,不知道对象实例化的是哪个类)。接下来给其except成员变量赋值['index'=>'img'],代表要是访问index这个变量,就会返回img。再给img赋值upload_img,让这个对象被访问不存在的方法时最终调用upload_img,如图2-68所示。
图2-68 UPLOAD题-13
接下来我们赋值控制filename_tmp和filename成员变量。可以看到前面两个判断我们只要不赋值和不上传变量即可轻松绕过。ext这里也要赋值,让它进入这个判断。而后程序就开始把filename_tmp移动到filename,这样我们就可以把png移动为php文件了。
我们还要构造一个Register,checker赋值为上面的$profile,registed赋值为false,这样在这个对象析构时就会调用profile的index方法,再跳到upload_img。
最终Poc生成脚本如下:
注意这里的文件路径,Profile的构造方法有切换路径,这里我们反序列化的话似乎不会调用构造方法,所以得自己指定一下路径。
运行上述代码,得到Poc,然后置coookie,如图2-69所示。
刷新页面,结果如图2-70所示。
图2-69 UPLOAD题-14
图2-70 UPLOAD题-15
可以看到我们的小马已经能访问了,如图2-71所示。
图2-71 UPLOAD题-16
连接“蚁剑”,打开/flag文件即可,如图2-72所示。
图2-72 UPLOAD题-17