bigywater.tech
Java · 403

ProcessBuilder:安全地启动外部命令

“启动一个外部命令”最容易写出安全与卡死的问题。这篇用真实的子进程量出:拼接字符串的注入后果、管道缓冲到底多大、环境变量与 PATH 的真实行为,以及各种启动失败的信息有多容易混淆。

PWNED_shell 被创建
把用户输入拼进 sh -c 字符串时,注入的 touch 命令真的执行了(作为数组参数时没有)
65536 → 70000 字节
Java 不读 stdout 时,子进程正常退出与被阻塞的分界(管道缓冲约 64 KiB)
Darwin vs FAKE-UNAME
子进程环境里改了 PATH:pb.start() 与 sh -c 分别运行的 uname

注入与参数切分

实测 · 本机执行输出
  拼接进 sh -c 的字符串:输出 hi;标记文件 PWNED_shell 被创建 = true
  作为单独的参数传给 echo:输出 hi; touch <data>/PWNED_args;标记文件 PWNED_args 被创建 = false
  必须用 shell 时,把输入放在位置参数里(sh -c 'echo "$1"' sh <输入>):输出 hi; touch <data>/PWNED_args2;标记文件 PWNED_args2 被创建 = false
  Runtime.exec(String) 按空白切分、不识别引号:echo 'a b' "c d" 的输出是 'a b' "c d"
  ProcessBuilder("echo", "a b", "c d") 的输出是 a b c d
  echo * 不经过 shell 时不展开:*;经过 sh -c 时被展开成工作目录里的文件名:one.txt PWNED_shell

管道写满

实测 · 本机执行输出
  子进程向 stdout 写 1000 字节、Java 不读:1.5 s 内正常退出
  子进程向 stdout 写 16384 字节、Java 不读:1.5 s 内正常退出
  子进程向 stdout 写 20000 字节、Java 不读:1.5 s 内正常退出
  子进程向 stdout 写 65536 字节、Java 不读:1.5 s 内正常退出
  子进程向 stdout 写 70000 字节、Java 不读:1.5 s 后仍然没退出(写满管道被阻塞)
  子进程向 stdout 写 1000000 字节、Java 不读:1.5 s 后仍然没退出(写满管道被阻塞)
  redirectOutput(DISCARD):写 1,000,000 字节,退出 = true
  redirectOutput(文件):退出 = true,文件大小 1000000
  只读 stdout、子进程大量写 stderr(1,000,000 字节):1.5 s 后仍未退出——stderr 管道写满
  redirectErrorStream(true) 并读完合并后的流:读到 1000005 字节,退出码 0
  redirectErrorStream(true) 的合并结果:out1 | err1 | out2 | err2

环境与 PATH

实测 · 本机执行输出
  子进程默认继承环境:printenv PATH 的前 30 个字符 = /opt/homebrew/opt/openjdk@21/b(与 Java 进程相同:true)
  子进程环境的 PATH 里把假的 uname 所在目录放在最前面:pb.start() 运行的是 Darwin(可执行文件的查找用的是 Java 进程自己的 PATH);而用 sh -c uname(由子进程里的 shell 按子进程的 PATH 查找)运行的是 FAKE-UNAME
  environment().clear() 之后 printenv 输出 0 个字符、退出码 0(即使子进程环境里没有 PATH,start() 也找到了 printenv)
  environment().put / remove:MY=1 HOME=(未设置)

启动失败

实测 · 本机执行输出
  命令不存在:IOException:Cannot run program "no-such-command-xyz": Exec failed, error: 2 (No such file or directory) 
  命令存在但工作目录不存在:IOException:Cannot run program "echo" (in directory "<data>/missing-dir"): Exec failed, error: 2 (No such file or directory) 
  文件存在但没有执行权限:IOException:Cannot run program "<data>/noexec.sh": Exec failed, error: 13 (Permission denied) 
  命令是一个目录:IOException:Cannot run program "<data>": Exec failed, error: 13 (Permission denied) 
  参数里含 NUL 字符:IOException:invalid null character in command
  空命令字符串:IOException:Cannot run program "": Exec failed, error: 2 (No such file or directory) 
  命令列表为空:ArrayIndexOutOfBoundsException:Index 0 out of bounds for length 0
  3 MB 的参数:IOException:Cannot run program "echo": Exec failed, error: 7 (Argument list too long) 
  退出码:sh -c 'exit 3' 的 waitFor = 3

分步演示

ProcessBuilder
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / OpenJDK(Temurin 17.0.20、Homebrew 21.0.12 与 27,每篇注明实际用的版本);用 javac 编译、java 直接运行,需要第三方库时用 Maven 解析依赖;计时用 System.nanoTime,数字每次运行会有小幅抖动。JDK 21.0.12,macOS arm64(管道缓冲大小、错误码与 PATH 查找都是平台相关的,Linux 与 Windows 不同);标记文件都在一个临时目录里
    • 没有验证:Windows 的命令行拼接与 cmd.exe 引号规则、Linux 的 posix_spawn / vfork 启动方式与 jspawnhelper、超时与僵尸进程的完整处理、ProcessBuilder.inheritIO 与终端交互、Runtime.exec(String[]) 与 ProcessBuilder 的细微差别
    京ICP备2026067502号-1京公网安备11010502063487号