一个 0 字节的载荷

归档里翻到这么个东西:

35shutdown/
  .gitkeep                                     0 B
  &shutdown -s -t 360&/
    .gitkeep                                   0 B
  新建文件夹/
    &shutdown -s -t 360&.zip                   474 B
    &shutdown -s -t 360&/&shutdown -s -t 360&/
    &shutdown -s -t 360& (2)/&shutdown -s -t 360&/

整个目录 1 KB,唯一的非零字节是那个 474 字节的 zip。把 zip 拆开:

        0  '&shutdown -s -t 360&/'
        0  '&shutdown -s -t 360&/.gitkeep'
        0  '.gitkeep'

三个条目全是 0 字节。 .gitkeep 是空文件,存在的唯一理由是让空目录能被 git 和 zip 带住——否则这个载荷在版本库里根本存不下。

也就是说:这个东西没有任何"内容"。它是一枚写在文件名里的命令。

(那两个 (2) 和套娃目录是反复解压留下的痕迹。而"名字能穿过一次 zip → 解包往返存活"这件事,本身就是测试成功的一部分。)


原理

&shutdown -s -t 360& 在文件系统里是完全合法的名字——& 不是 Windows 文件名的保留字符(保留的是 \ / : * ? " < > |)。所以它能被创建、被上传、被存储、被列出,一路都不触发任何警报。

语义的变化发生在有人把它拼进命令行的那一刻。很多后端代码长这样:

os.system("unzip " + upload_path)
os.system("tar xzf " + filename)
subprocess.run(f"copy {src} {dst}", shell=True)

把载荷代进去:

dir &shutdown -s -t 360&
    ^                ^
    命令在这里被切断,右边变成新的命令

cmd.exe 把 & 当命令分隔符,于是这一整行被解析成三条语句:

片段cmd.exe 的理解
dir执行 dir(当前目录)
shutdown -s -t 360执行关机,360 秒后
(空)无操作

第二条会真的跑起来。它不是"文件名被解析错了",它是"文件名根本不该出现在命令行里"。


为什么探针选 shutdown -s -t 360

这个选择比看起来讲究,它同时满足三条:

  1. 可逆。 -t 360 给了 6 秒×60 的窗口,shutdown -a 就能取消。用 shutdown /s /t 0 或者更暴力的东西,测试就会变成事故。
  2. 只在真的执行时才响。 如果目标系统只是把文件名存进数据库、或者打印到页面上,什么都不会发生。它不会误报——不像"让文件消失"那种探针,可能只是被正常清理掉了。
  3. 动静够大,不会看漏。 一个倒计时关机弹窗,不需要预先知道该盯哪个日志字段。

换个说法:好的 canary 要在"完全无效"和"不可挽回"之间找到那个窄缝。 太温和会漏报,太猛会背锅。


实测:五种写法哪种挡得住

光讲原理没意思,直接跑。我写了个 demo.py,它会真的创建这个目录、真的执行、然后看结果。

判据不是"输出里有没有出现某个词",而是"同级目录有没有多出一个 INJECTED_PROOF 文件夹"——原因下面单独讲。

载荷目录名 = '&mkdir INJECTED_PROOF&'

① os.system("dir " + path) 未加引号              注入成功   (探针目录存在=True)
② os.system('dir "' + path + '"') 加双引号       未触发     (探针目录存在=False)
③ subprocess argv ["cmd","/c","dir",path]        未触发     (探针目录存在=False)
④ 单引号包裹 dir '...'                           注入成功   (探针目录存在=True)
⑤ 完全不进 shell:os.listdir(path)               未触发     (读到 ['.gitkeep'])

两个反直觉的点:

双引号挡得住,单引号挡不住。 cmd.exe 没有"单引号是引用符"这个概念,' 对它来说就是普通字符。所以把 POSIX 那套习惯搬过来——比如直接用 shlex.quote() 包一下——在 Windows 上等于没防,因为 shlex.quote 产出的正是单引号。

argv 列表形式在这里安全,不是因为它"天生免疫"。 Python 在 Windows 上会把含空格的参数自动用双引号包起来,所以③本质上就是②。换句话说,它的安全是巧合性的:如果路径里没有空格,或者换成别的构造方式,结论可能就变了。

真正免疫的只有⑤:不进 shell。 文件名当字符串传给 API,从来不被任何解释器读过,也就无从注入。


我是怎么把这条测错的

上面那组数字是第二次测出来的。第一版的结果是①②③④全部"注入成功"——听起来触目惊心,实际上三个是假的。

第一版的 canary 叫 &echo PWNED_BY_FILENAME&,判据是"命令输出里有没有 PWNED_BY_FILENAME 这串字符"。

问题在于:dir 会把目录名原样回显出来,而那串标记就写在名字里。 所以只要 dir 成功执行(不管有没有被注入),输出必然包含这个标记。

① 的真实输出:
   <DIR>  &echo PWNED_BY_FILENAME&        PWNED_BY_FILENAME
          ^^^^^ 这是 dir 回显的名字 ^^^^   ^^^ 这才是注入的命令跑出来的 ^^^
② 的真实输出:
   ...\&echo PWNED_BY_FILENAME& 的目录      ← 只有名字,没有第二个标记,其实没注入

两行里都有那串字符,判据却分不出来。这是一个假绿:检查在什么都没发生的情况下报"命中"。

修法是换一类判据:别问"输出提到没提到",问"世界上有没有多出一个只有执行了才会存在的东西"。 于是 canary 改成 &mkdir INJECTED_PROOF&,判据改成"同级有没有 INJECTED_PROOF 这个目录"。探针和载荷不再共用任何可混淆的字符,②③才显出真实结果。

这条不只适用于安全测试——任何"跑一段代码然后看输出"的自检都吃这个亏。判据要落在状态变化上(新建的文件、改变的计数、发出的请求),不要落在文本匹配上,尤其当被匹配的文本本身就是输入的一部分时。


边界

  • 这只针对 cmd.exe。 POSIX shell 的元字符是另一套:; | & ` $() 换行,而且单引号在那里是真的引用符。同一个文件名在两边行为完全不同。
  • & 不是唯一的一种。 以 - 或 -- 开头的文件名会撞上参数注入(option injection)——程序没把它当路径,而是当成了一个开关。那是另一类问题,防御方式也不同(-- 分隔符、./ 前缀)。
  • 载荷里的 > < | 在 Windows 文件名里是非法的,所以可用字符集其实有限;但 & 恰好是合法又危险的那一个。

这个仓里只有测试载荷和复现脚本,不涉及任何真实目标系统。


东西在哪

载荷、复现脚本和完整说明:cnb.cool/t1mmoc/tspace/pwngore/h35-filename-injection

payload/&shutdown -s -t 360&/.gitkeep     载荷本体(名字就是命令)
payload/&shutdown -s -t 360&.zip          压缩包形式,能穿过上传口
demo/demo.py                              五种写法的实测脚本,用 mkdir 探针,不碰电源

想验自己的系统:直接 python demo/demo.py,它用 mkdir 当探针,安全。真要把 shutdown 版本丢给某个上传接口测,记得手边留着 shutdown -a。

顺带一句:这个载荷的名字能完好穿过 git clone——我克隆回来验过,两个 & 都在,demo 在新克隆里跑出同样五组结果。连版本库都原样带着它走,这大概就是这类东西最容易被忽略的地方。