☰
从命令注入到沙箱逃逸:一道CISCN Web题的完整攻击链复盘
2026/10/21 16:20:45 网站建设 项目流程

“比赛结束两周后,我重新打开这道题的时候,第一反应还是那句老话:赛场上真正难的不是漏洞本身,而是读懂出题人想让你走的那条路。今年CISCN的这道Web题,表面上是命令执行,实际上是把代码审计、系统命令拼接、过滤绕过、沙箱逃逸串成了一条完整的攻击链。从最开始一个不起眼的参数注入点,到最后拿flag需要突破的层层限制,全程几乎没有一步是多余的。”

“这篇文章我打算把这条链路完整复盘一遍。它适合两类人看:一类是准备打CTF的新人,可以把它当成一条‘命令执行到沙箱逃逸’的标准路线图;另一类是日常写业务系统的开发者,看看为什么一个看似有防护的服务,最后还是被步步拆穿。文章中涉及的代码和payload都经过脱敏重写,但攻击思路和调试流程完全保留。”

1. 一道Web赛题如何从单点漏洞演变成系统突破

1.1 赛题形态:看起来只是一个带批处理功能的搜索站点

这道题的入口特别像企业内部常见的日志检索工具。页面提供两个功能,一个是按关键词搜索文本文件内容,另一个是提交一个“批处理任务”,比如对某个日志文件执行格式化、统计、摘要之类的操作。后端是经典的Nginx + PHP环境,前端通过POST参数向PHP服务端提交任务名称和文件路径。

表面上看,这类系统只要在服务端规范拼接命令,就不会有太大问题。但赛题的真正考点就藏在这个“规范拼接”里。它没有用参数数组去调用系统命令,而是直接把用户传入的文件名丢进了shell命令模板,最终通过shell_exec执行。于是,命令注入的入口就出现了。

整道题的目标不是单纯执行一个whoami,而是读取根目录下的flag文件。但真正拿到flag之前,选手必须依次绕过三关:

  • 第一关:命令注入点的识别与过滤绕过;
  • 第二关:系统沙箱对可执行命令的白名单限制;
  • 第三关:禁用函数与文件读取策略下,如何把代码执行转化为实际的文件访问。

这种出题方式现在越来越常见。以前的Web题,考一个SQL注入或文件包含就结束了,现在则喜欢把漏洞阶段叠加起来,形成一条长链路。毕竟真实的攻击也是这样,外网一个低危漏洞往往只是跳板,能不能横向移动、能不能触及核心数据,才是关键。

1.2 为什么“命令执行”和“沙箱逃逸”会被放在一起

如果你只把这道题当成一个命令执行题,很容易在拿到id或者ls回显之后就卡住。原因在于,出题人故意在系统和敏感文件之间立了一道“软墙”。

这道墙没有阻断shell命令本身,但限制了你能执行的命令范围。它内部维护了一份白名单,只允许少数几个“看起来无害”的命令,比如ls、pwd、date。这意味着,即使你成功注入了一个完整的shell命令,如果命令的第一个词不在白名单里,就会被拦截。

也就是说,命令执行是入口,沙箱逃逸才是决定你能不能拿到flag的“最后一公里”。这也是为什么复盘的时候必须把两个攻击阶段串起来看。第一阶段的目标是“让命令能跑”,第二阶段的目标是“让命令能跑出规定区域”。只有把两个目标都完成,整个攻击链才算闭环。

2. 命令执行漏洞:定位注入点与构造绕过载荷

2.1 代码审计:从一处字符串拼接发现注入入口

赛题后端的关键调用逻辑简化后大概是这样的:

function run_task($taskName, $logFile) { $cmd = "/usr/local/bin/log_processor --task=" . $taskName . " --file=" . $logFile . " 2>&1"; $result = shell_exec($cmd); return $result; }

这个函数本身的问题非常典型:用户传入的$logFile直接拼入了一条系统命令,且命令整体通过shell_exec执行。正常情况下应该用escapeshellarg()对参数做转义,或者把命令和参数分开,用proc_open传数组方式调用。

但比赛不会给你一个裸奔的注入点。后端的公共入口函数做了过滤,黑名单包括空格、/、cat、flag、分号、管道符等常见攻击字符。第一次尝试时,我提交了一个普通文件名,发现正常回显;接着提交了一个分号加ls,直接被拦截,提示“非法字符”。

这一步其实很有价值:与其盲目堆payload,不如先快速摸清过滤边界。我当时的做法是把常见字符一个个提交上去,观察哪些被拦截、哪些能正常通过。几分钟后,过滤规律基本清楚了。

2.2 空格与斜杠的双重绕过:变量替换与通配符的组合

很多人在命令注入上第一脚就踩进坑里:明明知道有注入,但拼接出来的payload因为空格被过滤而无法执行。解决空格的办法并不少,最通用的是利用Bash的特殊环境变量IFS。

IFS是Shell内部字段分隔符,默认包含空格、制表符和换行。在命令拼接时,${IFS}可以替代一个空格。比如:

cat${IFS}/flag

但有的环境下${IFS}后面紧接着字符会被吞掉,常见处理是加上$9作为分隔:

cat${IFS}$9/flag

斜杠被过滤后也有对应的绕过思路。比如利用环境变量PATH截取首字符。在大多数Linux环境中,PATH的值以/开头,因此${PATH:0:1}就可以表示一个斜杠。更稳一点的是用${HOME:0:1},同样能拿到/。

这样,原本的cat /flag可以改写成:

cat${IFS}$9${PATH:0:1}flag

再配合关键字拦截绕过,例如用引号拆开敏感词,就成了:

c""at${IFS}$9${PATH:0:1}fla""g

至于cat本身被过滤的情况,可以用tac、tail、less、more等替代。如果它们也被列入黑名单,那就利用通配符,比如/???/???去匹配cat或ls。通配符的威力在于,它不需要你的命令里出现任何被过滤字符串,系统会替你完成匹配。

2.3 一个可用于现场验证的通杀型探测流程

在真正的比赛环境里,我不会一上来就猜flag路径,而是先用最轻量的命令确认注入是否成立、以及回显是否通畅。假设过滤规则和上面类似,可以用这样一个流程:

  • 第一步,提交正常参数,记住正常响应长度;
  • 第二步,在参数后追加%0a(换行符)或;,观察是否报错或回显变化;
  • 第三步,用${IFS}替代空格执行ls,确认命令执行;
  • 第四步,继续替换斜杠和关键字,读取根目录文件。

当时的实际调用大概是这样的:

curl -X POST http://target/index.php \ -d 'action=run&file=/tmp/test.log%0a/usr/bin/tac${IFS}$9${PATH:0:1}fla*'

第一次执行后,服务端把根目录下的flag文件名通过报错信息带了出来。这一步成功之后,距离“命令执行”这个目标就非常近了。

但很快我发现,直接读取flag不够——后续的tac命令虽然能执行,却总是被另一个外层拦截逻辑掐断。回头去读源码才发现,这个应用在调用run_task之前,还套了一个TaskRunner类,类内部有独立的命令白名单校验。这个发现让整道题从“命令执行”直接切换到“沙箱逃逸”阶段。

3. 沙箱逃逸:当命令执行被白名单锁死之后

3.1 沙箱实现并不复杂,但设计上留了缝隙

大多数CTF赛题里的沙箱,不是真正意义上的操作系统级隔离,而是应用层的过滤逻辑。它可能是一个PHP类、一个模板解析器、或者一个恶意函数检测中间件。这道题属于前者:一类TaskRunner,做的是命令白名单校验。

简化后的代码大约是这样:

class TaskRunner { private $allowed = ['ls', 'pwd', 'date', 'id']; public function run($input) { $parts = explode(' ', $input); if (!in_array($parts[0], $this->allowed)) { throw new Exception("Command not allowed"); } return shell_exec($input); } }

第一眼看上去似乎没问题:只允许ls、pwd、date、id这几个“安全”命令。但你仔细看校验逻辑,它只用空格对输入做了切分,然后只检查第一个单词是否在白名单里。后续的命令部分既没有做参数校验,也没有对分号、换行符、管道符做任何限制。

这意味着,你完全可以在一个白名单命令后面接上真正想执行的命令:

ls; cat /flag

因为explode(' ', ...)得到的第一段是ls;,如果白名单匹配的是ls,可能仍然会放行。更直接的情况是,第一段是ls,第二段开始就是任意命令。比如:

ls /; /usr/bin/tac /flag

这类“只校验首词”的沙箱缺陷,在真实系统里也屡见不鲜。很多WAF规则只关注攻击者输入的第一个关键字,而忽略了一整条命令链。绕过本质上就是利用校验逻辑与真实执行逻辑之间的落差。

3.2 利用PHP原生能力替代系统命令

如果外层的命令沙箱实在绕不过去,另一个思路不是系统命令,而是PHP自身的能力。开发者往往会拦截system、exec、shell_exec等危险函数,却在同一个应用里留下大量可用的文件操作函数。

例如GlobIterator可以从PHP层面列目录,SplFileObject可以直接读取文件。如果沙箱只拦了命令执行,没有拦这些PHP类,那你甚至不需要系统命令就能拿到flag。

一个典型的读取思路:

$it = new GlobIterator('/flag'); foreach ($it as $f) { echo $f->getFilename() . "\n"; }

这类代码能否执行,取决于注入点是否支持代码执行。如果入口只是命令拼接,那么得先想办法把代码执行“喂”进去。很多赛题在出第二层时,会提供一个间接代码执行的接口,比如preg_replace回调、assert、模板渲染,甚至是一个可以被包含的文件写入点。

如果你已经能写文件到web目录,那更直接的办法是写一个PHP一句话文件,再用包含或直接访问的方式执行PHP代码。沙箱可以限制命令,但限制不了Web服务对PHP文件的解析。这是应用层沙箱的一个致命盲点:它只守住了命令入口,却没有守住文件落地的路径。

3.3 通过FFI调用libc直接突破禁用函数

还有一种相对高级的绕过,针对的是PHP的disable_functions配置。很多真实站点的安全加固会通过php.ini把system、exec、passthru、shell_exec全部禁用,但如果你能执行任意PHP代码,禁用列表就只对普通函数有效。

PHP的FFI扩展允许在PHP内部直接声明并调用C函数。只要ffi.enable为true,就可以绕过PHP函数级别的限制,直接加载libc.so.6并调用system()。

$ffi = FFI::cdef("int system(const char *command);", "libc.so.6"); $ffi->system("cat /flag");

这个思路在最底层绕过了“函数黑名单”的逻辑,因为FFI调用的是系统C库,而不是PHP内置的高级函数。虽然这类手段在真实攻击中需要满足PHP 7.4以上且开启FFI,但在赛题环境中,它经常是压轴的拿分点。出题人如果忘了关闭FFI,前面辛苦构建的函数禁用策略就形同虚设。

3.4 模板引擎沙箱的关联思路

如果是模板渲染页面存在注入,对应的沙箱绕思路会有些区别。这类沙箱通常控制的是“模板中允许访问的变量和方法”,而不是系统命令本身。常见的做法包括:

  • 查找模板对象中的__call、__toString、__get等魔术方法,利用它们间接调用危险函数;
  • 通过现有对象链访问全局作用域,找到可用的危险对象;
  • 利用模板引擎内置的过滤器或函数,比如动态调用任意类方法。

无论哪类沙箱,核心逻辑都是找“白名单没有覆盖到,但执行引擎又允许调用”的缝隙。你可以把它想象成机场安检:查行李查得很严,但如果你能找到一个员工专用通道,安检规则就完全不是障碍了。

4. 攻防视角下的完整链路复盘

4.1 攻击视角:七步走完一条攻击链

把整道题的攻击路径按时间线摊开,大致是七个阶段:

  1. 发现攻击面:找到一个带文件参数的批处理功能,参数在服务端被拼入shell命令。
  2. 验证注入点:通过换行符和ls确认命令执行,同时摸清首层过滤规则。
  3. 绕过基础过滤:用${IFS}替代空格、用${PATH:0:1}替代斜杠、用fla*替代完整的flag关键字。
  4. 触发沙箱拦截:直接读取/flag时被外层校验拦截,发现只能执行白名单内的少数命令。
  5. 读取应用源码:借助注释泄露或报错信息,找到TaskRunner类的实现逻辑。
  6. 构造沙箱绕过:利用“只校验首词”的缺陷,拼接ls; /usr/bin/tac /flag这类组合命令。
  7. 扩大战果:在成功读取flag后,进一步测试是否能用FFI或写文件方式获得更稳定的控制。

第3到第5步是多数人会卡住的地方。很多人验证完注入点就直奔flag,忽略了中间那层“看似存在但没完全拦住”的限制。真正有效的做法是:每发现一个拦截,就把它当新的一关,先搞清它的边界,再想绕过方式。

4.2 防守视角:沙箱设计到底哪里出了问题

站在出题人角度,这道题的“防守”其实是有意为之的。它不想让你直接拿到flag,而是希望考验你对沙箱逻辑的理解。但从一个安全开发者的视角看,这个沙箱暴露了好几个必须反思的问题:

  • 白名单只匹配首词:这是最严重的错误。命令的首词只是整条命令的开头,并不代表整条命令的意图。
  • 没有对参数做独立校验:即使首词是ls,也可以带出任意路径和任意附加命令。
  • 应用层沙箱与命令执行混在一起:既然应用本身有命令执行的必要,就把沙箱隔离在独立进程或容器里,而不是在一个PHP类里做字符串匹配。
  • 忽略了PHP层文件能力:只防了命令,没防文件读取。一个SplFileObject就能绕开全部命令限制。

这些教训放到真实系统里同样成立。很多所谓的安全加固,都是在函数入口加个敏感词过滤,以为把cat和flag封掉就安全了。但其实攻击者真正依赖的是底层语义,而不是某个具体字符串。只要你能执行一条命令,就有办法把任何敏感词拆碎重组,把任何路径变量化。

4.3 三层修复建议:从应急修补到架构防御

如果这道题要修复,最低成本的方案是在PHP层使用escapeshellarg()对每个外部输入做转义,并且不要用字符串拼接构造命令。但这只挡住了第一层命令注入,挡不住应用层沙箱绕过。

稍微合理的修复,是把命令拼接改成参数数组方式,使用proc_open或passthru直接传递参数列表,不经过shell解析。这样即使输入里带分号或换行符,也无法成为新的命令。

更彻底的修复,是把需要执行命令的任务封装成一个独立服务,运行在最小权限容器中,通过API接收参数并执行预先写好的命令模板。这样即使攻击者能控制参数,也无法脱离容器边界。这就是从“过滤输入”走向“限制能力”的防御思路。真实的Web服务如果对命令执行有刚需,优先考虑这种架构隔离,而不是在同一个进程里“猜黑名单”。

5. 实操中的高频卡点与排查经验

5.1 五个最容易让人浪费大量时间的卡点

先总结一下我在比赛现场和后续复现过程中遇到的几个高频问题:

  • 命令有执行但无回显:很多环境把shell_exec的返回值直接丢弃,只返回“任务完成”。这时候要用sleep、curl这类带外通信或时间盲注来确认。看到延迟再判断注入是否成立。
  • 黑名单过滤比你想象的更宽:有时候你以为只禁了空格,实际上连$、{、}、/都禁了。所以先做字符探测很有必要,不要上来就套复杂payload。
  • 通配符匹配到多个文件:/???/???在某些目录下会匹配到多个程序,导致命令报错。这时候要缩小匹配范围,或者先用echo列一下当前匹配结果。
  • 沙箱拦的不是命令,而是整个输出:有的题目并不拦截命令执行,只拦截包含flag字样的响应内容。此时可以把flag内容做编码处理,比如用base64输出,再用本地解码。
  • PHP版本差异导致某类payload失效:老版本PHP可能支持${语法扩展,新版本PHP对变量函数、FFI的要求又不同。做题前先看phpinfo()确认环境是最稳妥的。

5.2 一个自动化的候选载荷探测脚本

为了减少反复手动测试的时间,我赛后写了一个简单的Python脚本,用来遍历常见绕过载荷。思路很简单:向目标接口发送POST请求,每次替换file参数中的测试payload,然后通过响应长度和关键词来识别哪条载荷能绕过拦截。

import requests url = "http://target/index.php" payloads = [ "test.log%0als", "test.log%0a${IFS}ls", "test.log${IFS}$9ls", "test.log%0a/usr/bin/id", "test.log%0a/usr/bin/tac${IFS}${PATH:0:1}flag", "ls; /usr/bin/tac /flag", ] for p in payloads: data = { "action": "run", "file": p } r = requests.post(url, data=data, timeout=10) marker = "flag" if "flag" in r.text else "id" if "uid=" in r.text else "" print(f"{p!r:60} len={len(r.text):5} marker={marker}")

这个脚本的价值不在于一梭子打进去就能出结果,而是帮你快速筛选出“哪些字符被拦截、哪些字符能生效”。根据输出结果,再手工构造最终payload,效率会高很多。

5.3 复盘比刷题更重要的一个习惯

比赛结束后,我习惯把所有试过的payload按类别记下来,包括原始请求、被过滤字符、绕过思路和最终成功载荷。这次复盘时,我发现很多绕过技巧其实都是相通的:

  • 空格问题,永远先考虑${IFS};
  • 斜杠问题,优先考虑环境变量截取;
  • 敏感词问题,优先考虑引号拼接和通配符;
  • 白名单问题,优先考虑分号、换行符、管道符这类命令分隔符;
  • 函数禁用问题,优先考虑PHP原生类、FFI和文件读写能力。

把这些规律整理成一张“命令绕过速查表”,下次遇到类似的注入点,就从表里按顺序试,不需要再现场一步步猜。

写在最后的一点个人体会

这道题给我最大的启发,不是某个payload多巧妙,而是攻防双方的思路差异。攻击者永远在看“校验逻辑和执行逻辑之间有没有错位”,而防御者往往只关心“我拦住了哪些已知特征”。命令注入的入口可能很简单,但沙箱这道墙决定了攻击成本。真正有效的安全设计,不是把黑名单加到多长,而是尽早让攻击者没有机会执行完整命令、没有权限进入敏感区域。

如果你也准备参加类似CTF比赛,建议多花点时间研究沙箱的实现方式,尤其是那些看起来“只允许安全命令”的白名单逻辑。它们往往藏着最容易被绕过的缝隙。把这些思路吃透了,你在赛场上就不会被一道所谓的“进阶题”卡住太久。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询