
1. 项目概述与核心价值最近在带新人做渗透测试的靶场练习发现很多朋友一遇到命令执行RCE的题目尤其是那种过滤了字母、数字甚至空格的“变态”关卡就有点无从下手。他们往往知道用system()函数能执行命令但一旦题目加了点限制思路就卡住了。这让我想起了一个非常经典的靶场——RCE-labs它几乎把PHP环境下特别是围绕system()这类函数的花式绕过技巧给玩出花了。这个靶场的设计初衷就是帮你系统性地收集和练习各种RCE技巧。它从最基础的代码执行和命令执行概念讲起一路深入到无字母数字执行、长度限制绕过、环境变量注入等高级手法。对于Web安全入门者、CTF选手甚至是想要巩固RCE知识点的安全工程师来说这都是一个绝佳的“练功房”。今天我就以这个靶场为蓝本带你深入拆解那些在不依赖传统字母数字字符的情况下如何利用系统特性、Shell语法和PHP本身的“骚操作”来实现命令执行。你会发现绕过限制的关键往往不在于记住多少Payload而在于理解背后的原理。2. 靶场环境与基础概念扫盲在开始“骚操作”之前我们得先把地基打牢。RCE-labs靶场通常以Docker容器形式部署每个关卡对应一个独立的Web应用漏洞场景。核心的漏洞点往往是一个使用了危险函数如system()、exec()、shell_exec()的PHP脚本并且对用户的输入进行了某种形式的过滤。2.1 核心危险函数system()与它的兄弟们PHP中能执行系统命令的函数不止一个它们各有特点函数输出行为返回值典型用法system(string $command)直接输出命令执行结果到标准输出通常是浏览器。返回命令执行结果的最后一行或false失败。system($_GET[‘cmd’]);exec(string $command)默认不直接输出。结果可以存入第二个参数指定的数组。返回命令执行结果的最后一行。exec($_GET[‘cmd’], $output); print_r($output);shell_exec(string $command)不直接输出将全部输出作为字符串返回。返回包含全部输出的字符串失败返回null。echo shell_exec($_GET[‘cmd’]);passthru(string $command)直接输出原始二进制数据如图像。无返回值。passthru(‘cat /etc/passwd’);反引号 功能同shell_exec()。返回包含全部输出的字符串。echo $_GET[‘cmd’];为什么system()是靶场常客因为它最“直白”。输入什么命令就在页面上看到什么结果对于初学者理解“输入即执行”的概念非常直观。同时它的执行环境默认通过/bin/sh调用也为我们后续利用Shell特性进行绕过提供了舞台。2.2 命令执行 vs. 代码执行别搞混了这是两个容易混淆但必须分清的概念命令执行 (Command Execution)目标是操作系统的Shell命令。比如cat /flag、ls -la、whoami。我们利用system()、exec()等函数实现的就是命令执行。代码执行 (Code Execution)目标是编程语言本身的代码。比如PHP的eval(‘echo “hello”;’)、Python的exec(‘print(“hello”)’)。这通常需要利用eval()、assert()PHP 8.0前或动态函数调用等。关键区别代码执行的权限和灵活性通常更高因为它是在当前脚本的上下文和权限中运行任意代码。而命令执行受限于系统Shell和Web服务器进程的权限。但在CTF或漏洞利用中我们最终的目标往往是“执行命令”所以代码执行漏洞常常被用作跳板去调用system()等函数来达到命令执行的目的。RCE-labs的早期关卡就很好地演示了这一点。3. 基础绕过当命令执行遇上简单过滤靶场不会让你轻易?cmdcat /flag就拿到flag。最初的几关会引入一些基础的过滤和绕过。3.1 Shell连接符的妙用假设后端代码是system(“ping -c 1 “ . $_GET[‘ip’]);意图让你输入一个IP地址。但如果我们输入的不是IP呢Shell提供了多种连接符可以让多个命令按顺序或条件执行运算符作用示例 (Payload)执行逻辑;命令分隔符。无论前一个命令成功与否都执行下一个。?ip127.0.0.1;cat /flag先ping然后执行cat。逻辑与。只有前一个命令成功返回0才执行下一个。?ip127.0.0.1cat /flagping成功才cat。****逻辑或。只有前一个命令失败返回非0才执行下一个。后台执行符。前一个命令放后台立即执行下一个。?ip127.0.0.1cat /flagping在后台执行同时执行cat。****管道符。将前一个命令的输出作为后一个命令的输入。?ip127.0.0.1或$()命令替换。先执行反引号或$()内的命令将其输出作为外部命令的参数。?ip127.0.0.1;cat find / -name flag* 2/dev/null先执行find命令找到flag文件再cat。实操心得在URL中需要编码为%26否则会被解析为URL参数分隔符。最常用的是;和因为它们最直观。||在需要“失败触发”的场景下很有用。3.2 空格与敏感词过滤的绕过当关卡开始过滤“空格”和“flag”这类关键词时就需要一点技巧了。1. 空格的替代方案空格在Shell中本质是由IFS(Internal Field Separator) 变量定义的。默认是空格、制表符、换行符。我们可以利用这个特性。${IFS}这是最标准的替代。cat${IFS}/flag。$IFS在某些环境下也能用但不如${IFS}稳定。或重定向符号可以吃掉空格。cat/flag或cat/flag。制表符%09URL编码的制表符。cat%09/flag。花括号{}在bash中{cat,/flag}等同于cat /flag。注意括号内不能有空格且用逗号分隔。2. 命令/路径中的字符绕过如果过滤了cat或flag这样的字符串。通配符*和?cat /fla*匹配/flag、/flag.txt等。/???/cat /??a?/???/cat可能匹配到/bin/cat/??a?可能匹配到/flag。这需要一点运气和目录结构知识。变量拼接ac;bat;cfla;dg;$a$b /$c$d先将命令和路径拆分成变量再拼接执行。反斜杠转义ca\t /fla\g。反斜杠在某些过滤逻辑中可能被忽略但命令解释时仍有效。引号包裹cat /f’‘lag或cat /f””lag。单引号或双引号在Shell中定义一个字符串空字符串会被忽略从而切断了关键词。编码/解码Base64:echo “Y2F0IC9mbGFn” | base64 -d | bash。将命令编码后管道解码执行。十六进制:echo 636174202f666c6167 | xxd -r -p | bash。xxd -r -p将十六进制字符串还原为二进制。八进制: 后面会详细讲这是无字母数字的基石。注意事项通配符技巧高度依赖于目标系统的文件路径。在Docker等精简环境中/bin/cat和/flag的路径相对固定成功率较高。但在真实复杂环境中需要更多尝试。4. 进阶魔法无字母数字的命令执行这是RCE-labs靶场的精华部分也是很多CTF题目的难点。当过滤了所有字母a-z, A-Z和数字0-9后我们还能执行命令吗答案是肯定的核心在于利用Bash Shell的字符串表示和算术扩展特性。4.1 基石Bash的八进制转义与算术扩展Bash允许使用$’\xxx’的语法来表示一个八进制值对应的ASCII字符。例如$’\154’就是字母lASCII 154的八进制是154。所以ls可以表示为$’\154\163’。但光能表示字符不够我们还需要能进行算术运算来“生成”这些八进制数字。Bash的$(( ))结构可以进行整数运算。关键来了$((2#1010))这个表达式表示将二进制1010转换为十进制结果是10。这里的2#表示“二进制”。那么如果我们能用非字母数字的字符构造出2和1010这样的二进制字符串不就能通过运算得到任意数字进而构造出任意字符了吗4.2 从Level 9到Level 13的进化之路靶场通过几个关卡层层递进地展示了如何用越来越少的字符集构造命令。Level 9: 八进制转义这是入门。允许的字符集相对宽松包含了数字。所以我们可以直接构造八进制Payload?cmd$’\143\141\164’$’\40\57\146\154\141\147’这相当于cat /flag。注意空格ASCII 32八进制40也需要转义。如果空格被过滤可以用${IFS}或替代。踩坑记录这里有个大坑system()函数在PHP中默认通过/bin/sh来执行命令。在很多Linux系统上/bin/sh是dash的软链接而不是bash。而dash不支持$’\xxx’这种C语言风格的字符串转义语法所以你的Payload在本地bash终端测试成功通过system()执行却可能失败。靶场为了演示特意修改了镜像将/bin/sh链接到了/bin/bash。在实际渗透中这是一个需要判断的点。Level 10: 二进制整数替换这一关禁用了数字但允许#。我们可以用$((2#binary))来表示数字。那么2怎么来可以用111左移一位得到。所以2可以写成$((11))。 那么字符l(八进制154十进制108) 的二进制是1101100。我们可以构造$\’\\$(($((11))#1101100))\’来表示l。 但这还不够因为$’\xxx’语法期望的是一个八进制数字而我们给了一个算术表达式。我们需要让Shell对这个表达式进行两次求值。这里用到了Here String() 和$0当前Shell的名称通常是bash$0$\’\\$(($((11))#1101100))\\$(($((11))#10100011))\’$0就是bash将后面的字符串作为标准输入传给bashbash会去解析这个字符串从而执行了转义后的命令ls。Level 11: 构造数字“1”这一关进一步禁用了数字1。如何得到数字1${#}表示传递给脚本的参数个数。在直接执行命令时这个值是0。${##}这里第一个#是参数个数操作符第二个#是变量名。${#}是0${##}就是求变量#值的长度。变量#的值是0字符串其长度为1。所以${##}的结果就是1。 于是我们可以用${##}来替换之前Payload中的所有1。Level 12 13: 构造数字“0”和更严格的限制Level 12 禁用了0。如何得到0$(())空算术表达式的结果是0。${!var}间接变量引用。如果var0那么${!var}就是$0即bash。但这里我们需要的是数字0本身而不是变量名。更常用的方法是利用未定义变量的默认值。但靶场采用了另一种思路利用${!?}等特性这在不同的Shellbash/sh中行为不一致是另一个容易踩坑的地方。Level 13 则是在更严格的字符集下通过叠加-1和取反~来构造出0到7的所有数字因为八进制只需要这8个数字。例如$(())是0。$((~$(($((~$(())))$((~$(())))))))这个看起来很复杂的东西其实就是1。原理是$(())是0~0是-1(按位取反)。$(( -1 -1 ))是-2。~(-2)是1。通过这种-1叠加再取反的方式可以构造出任意正整数。核心思路总结无字母数字RCE的终极目标就是利用Shell的算术扩展$(( ))和字符串表示$’\xxx’通过仅有的几个特殊字符如$,(,),#,~,_等进行运算像搭积木一样构造出我们需要的命令字符串。这就像一种特殊的“Shell编程”只不过代码极度晦涩。5. 极限挑战长度限制下的RCE如果说无字母数字是“字符集”的限制那么长度限制就是“空间”的限制。RCE-labs的14-16关模拟了这种场景你每次只能输入极少数字符如7个、5个、4个如何组合起来完成任意命令执行5.1 核心思路文件构造与时序执行当单次输入长度极短时我们无法一次性写入完整的命令。解决方案是分多次输入将命令片段写入临时文件最后通过某个机制一次性执行这个文件。关键技术点命令拼接符\在Shell中行尾的\表示命令延续到下一行。我们可以用l\和s两次输入最终在Shell看来是ls。输出重定向a会创建或覆盖一个名为a的文件。echo hello a会把hello写入文件a。按时间排序ls -tls -t会按文件修改时间从新到旧列出文件。如果我们按顺序创建文件a、b、c那么ls -t的输出就是c b a。文件执行shsh a会把文件a的每一行当作命令来执行。5.2 以7字符限制为例假设我们每次只能输入7个字符目标是执行cat /flag。步骤拆解写入命令到文件我们需要把cat /flag这个命令写入一个文件。由于长度限制我们需要拆开写。cat\(创建文件cat\不对这里需要技巧) 实际上我们可以利用\创建文件名带空格的文件但更通用的方法是分多行写入同一个文件。更经典的Payload是ls\和a。这创建了两个文件名字分别是ls\和a。注意ls\这个文件名末尾有一个反斜杠但在文件系统中它就是一个普通字符。构造执行序列我们需要创建一个文件其内容是按顺序执行我们写入的命令片段。这里要用到ls -t和输出重定向。ls -tg这条命令会执行ls -t并将其输出按时间排序的文件名列表重定向到文件g中。假设我们按顺序创建了文件a、b、c那么g文件的内容就是c b a每个文件名占一行。但我们需要的是命令而不是文件名。所以我们需要让ls -t输出的文件名本身就是可执行的命令。这就是为什么第一步创建的文件名是ls\和a。如果ls -t输出a ls\那么当sh执行这个文件时就会尝试执行a和ls\这两个“命令”。组合Payload一个经典的7字符Payload序列如下假设服务器会按顺序执行我们提交的每一条7字符命令ls \\ l\a -t\\ \g ls -tg sh gls \\创建文件名为ls \的文件注意空格和反斜杠。l\a创建文件名为la的文件。当Shell执行ls -t时la会被输出。当sh执行包含la的文件时会将其解析为命令l和重定向到文件a但l不是命令这可能会出错。更精妙的Payload会避免这个问题。-t\\创建文件-t\。\g创建文件g。ls -tg执行ls -t将按时间排序的文件名g、-t\、la、ls \写入文件g。注意由于是重定向符号g这个文件名在ls -tg中会产生歧义。因此真正的Payload会使用ls -t_或ls -tg但通过其他方式规避这里为了演示简化了。sh g执行文件g中的内容。实际操作中的简化理解在真实CTF题目中通常会给你一个可以多次提交参数的地方比如一个表单。你可以通过多次提交将一段Base64编码的命令逐步写入一个文件然后使用base64 -d file | bash来解码并执行。例如echo PD8 a(写入?的base64)echo KCRf a(追加($_的base64)...base64 -d a | bash执行。注意事项这种技巧极度依赖于服务器的环境是否有sh、ls、等以及题目是否真的允许你通过多次请求“记住”之前的状态比如通过Session或文件系统。在无状态HTTP请求中通常需要配合文件上传、日志写入等能产生持久化影响的漏洞点。6. PHP特性在RCE中的另类利用除了在Shell层面玩花样PHP语言本身的一些特性也能成为绕过过滤的利器。RCE-labs的后半部分关卡重点展示了这些。6.1 动态函数调用PHP中如果一个变量保存了函数名那么可以用$variable()的形式来调用这个函数。$a $_GET[‘a’]; // 假设传入 asystem $b $_GET[‘b’]; // 假设传入 bcat /flag $a($b); // 等价于 system(‘cat /flag’);即使过滤了system字符串我们也可以通过其他方式如字符串拼接、编码动态构造出函数名。6.2 自增运算符与字符串构造PHP中对字符串进行自增操作有特殊效果‘a’会变成‘b’‘z’会变成‘aa’。我们可以利用这个特性从一个初始字符如‘a’构造出我们想要的任何字母。$_ ‘a’; $_; // $_ ‘b’ $_; // $_ ‘c’ // ... 一直自增到我们想要的字母结合.拼接运算符我们可以构造出‘system’这样的函数名或者‘cat’这样的命令。这在无字母数字过滤但禁用了某些关键函数名直接出现时有用。6.3 无参数函数调用有些题目限制得非常死不允许任何参数。例如eval($_GET[‘code’])但你的code不能包含括号()和参数。这时就需要利用一些返回有用信息的无参数函数。localeconv()返回包含本地化数字和货币格式信息的数组其第一个元素通常是小数点.。current()/pos()返回数组中的当前元素第一个。scandir()列出指定目录中的文件和目录。array_rand()从数组中随机取出一个或多个键。array_flip()交换数组中的键和值。readfile()/highlight_file()/show_source()读取文件内容。一个经典的Payload链?codevar_dump(scandir(current(localeconv())));localeconv()[0]是.。current(localeconv())得到.。scandir(‘.’)列出当前目录。发现flag.php在列表中。下一步需要读取文件。但不能用readfile(‘flag.php’)需要参数。可以用show_source(array_rand(array_flip(scandir(current(localeconv())))));。array_flip(scandir(‘.’))将文件名和数字索引互换。array_rand(...)随机返回一个键即一个文件名。show_source(文件名)高亮显示文件源码。多刷新几次随机到flag.php就能看到flag。6.4 取反与异或绕过这是无字母数字WebShell的经典技术常用于构造assert($_POST[‘x’])这样的后门。取反 (~)PHP中~运算符对字符串进行按位取反。~”abc”会得到另一个字符串。我们可以先计算出我们想要的字符串的取反值然后在代码中用~”取反后的字符串”来得到原字符串。// 我们想要 $_ “assert”; // 先计算 “assert” 的取反值 // 可以通过 php -r ‘echo urlencode(~”assert”);’ // 得到 %9e%8c%8c%9a%8d%8b $_ ~”%9e%8c%8c%9a%8d%8b”; // $_ 现在是 “assert” $__ ~”%a0%af%b0%ac%ab”; // $__ 现在是 “_POST” $___ $$__; // $___ $_POST $_($___[_]); // assert($_POST[_])异或 (^)两个字符串进行异或运算可以得到第三个字符串。如果我们的字符集里只有某些特殊字符可以通过它们两两异或来产生字母数字。// 例如我们只有 ‘!’ 和 ‘’ 等字符 // ‘!’ 的ASCII是33’‘是64’a’是97。 // 我们可以找两个字符使它们的异或结果是’a’。比如 33 ^ 64 97 (‘a’) // 但PHP中字符串异或是按位进行的所以需要精心构造。 // 通常使用脚本生成如 $_(‘%01’^’’).(‘%13’^’’)… 来拼接出 “assert”。实操心得取反和异或绕过在实战中主要用于WebShell的免杀将敏感函数名和变量名隐藏起来绕过基于字符串匹配的WAF或IDS。在RCE场景下如果过滤了system等函数名但允许执行任意PHP代码就可以用这种方法动态构造出函数名。7. 环境变量注入与其它高级技巧7.1 环境变量注入Level 18这是比较新且威力巨大的一种技巧。PHP的putenv()函数可以设置环境变量。在Linux的Bash shell中环境变量可以用来定义函数特别是以BASH_FUNC_开头的环境变量。漏洞原理如果攻击者能控制环境变量的名和值比如通过putenv(“MYVARvalue”)并且后续进程通过system()、exec()等启动了新的Bash shell那么攻击者可以定义这样一个环境变量BASH_FUNC_echo()() { cat /flag; }。这实际上定义了一个名为echo的Bash函数其内容是cat /flag。当新的Bash shell启动后如果它执行了echo命令就会优先执行这个函数而不是内置的echo命令从而执行我们注入的代码。靶场示例foreach($_REQUEST[‘a’] as $key $val) { putenv(“{$key}{$val}”); } system(‘echo hello’);Payload:?a[BASH_FUNC_echo%25%25]()%20{%20cat%20/flag;%20}注意%%需要URL编码一次变成%25%25因为PHP在处理数组键名时会解码一次。最终putenv设置的是BASH_FUNC_echo%() { cat /flag; }。在Bash 4.4及以上版本函数名中的%会被特殊处理。影响与限制该漏洞需要目标系统使用Bash并且版本受影响。此外需要能通过putenv()等函数控制环境变量并且后续有启动子Shell的过程。在共享主机、容器或某些CGI环境下可能成功。7.2 通配符的极致利用Level 6 扩展在Linux中通配符*和?是由Shell展开的。system()函数调用Shell所以我们可以利用通配符来匹配命令和参数。命令ls可以用/???/?s来匹配假设/bin/ls。文件/flag可以用/??a?来匹配。所以Payload可以是/???/?s /??a?。这可能会展开为/bin/ls /flag但实际执行的是ls /flag吗不Shell展开后传递给system()的字符串是/bin/ls /flag这相当于执行了/bin/ls这个绝对路径的命令。这绕过了对ls命令本身的过滤如果存在的话。更进一步如果连/都被过滤可以用${HOME:0:1}来获取/$HOME通常是/home/username其第一个字符是/。或者用$(echo . | tr ‘!-0’ ‘“-1’)这种奇怪的变换来得到/。8. 总结与防御建议通过RCE-labs这个靶场我们几乎遍历了PHPsystem()函数下RCE的各种“花式”技巧。从基础的连接符、空格绕过到中级的无字母数字构造再到高级的长度限制、PHP特性利用和环境变量注入其核心思路无非以下几点理解执行环境你的命令最终是由谁/bin/sh?bash?dash?在什么上下文Web服务器用户权限、容器环境中执行的这决定了哪些特性可用。利用语言和Shell特性Shell的运算符、变量扩展、通配符、进程替换PHP的动态调用、字符串操作、类型转换。这些都是绕过的“原材料”。分而治之当一次输入受限时考虑通过多次操作写文件、设置环境变量来组合完成攻击。编码与变换当直接字符被禁止时考虑八进制、十六进制、Base64、取反、异或等编码方式。给开发者的防御建议永远不要直接使用用户输入拼接命令。这是铁律。使用白名单如果必须执行命令只允许特定的、预定义的命令和参数。严格过滤与转义对所有用户输入进行严格的过滤和转义。但要注意黑名单永远会漏掉一些奇技淫巧。使用安全的API用escapeshellarg()或escapeshellcmd()函数处理参数。但注意它们并非银弹在复杂命令拼接中仍可能被绕过。降低权限运行Web服务的进程使用最低必要权限的用户并配置适当的文件系统权限。禁用危险函数在php.ini的disable_functions中禁用system()、exec()、shell_exec()、passthru()、proc_open()、popen()等函数。使用沙盒或容器将可能执行命令的应用隔离在沙盒或容器中限制其能访问的资源。最后研究这些绕过技巧的目的不是为了攻击而是为了更好的防御。只有充分了解攻击者的手段才能构建更坚固的防线。RCE-labs这样的靶场正是我们安全从业者锤炼技艺、理解漏洞本质的绝佳场所。建议大家在可控环境中多加练习将这些知识内化在代码审计和渗透测试中才能一眼看穿那些看似无害的system()调用背后潜藏的巨大风险。