资讯动态

Java执行外部命令的安全重构:从Runtime.exec到ProcessBuilder

发布时间:2026/10/18 23:57:29 来源:尧图企业网站定制
说实话每次 code review 看到Runtime.getRuntime().exec(cmd)这种写法我都会多停几秒。如果 cmd 还是字符串拼接出来的那就不是停几秒的问题了——我脑子里会直接拉响警报。这不是矫情而是过去几年里我确实亲眼见过、也亲手排查过太多因为这一行代码引发的线上事故有命令注入被打穿服务器的有参数被 shell 拆得七零八落导致程序行为异常的还有在 Windows 和 Linux 上行为不一致闹得运维半夜打电话的。这篇文章不是要全盘否定Runtime.exec()而是要搞清楚一件事当你在 Java 里需要执行外部命令时到底应该怎么传参才能既安全又可靠。我会从命令注入的原理讲起把Runtime.exec()拼字符串会踩的坑一个个列出来然后给你一套可以直接落地的ProcessBuilder重构方案最后附上我这些年在真实项目里积累的排查技巧。内容适合所有写 Java 后端、写自动化工具、或者维护遗留系统的开发者无论你刚工作两年还是已经带团队都应该系统的看一下这个问题因为你永远不知道哪行拼接字符串的命令会在什么时候变成一颗雷。1. 内容整体设计与思路拆解1.1 Runtime.exec() 的前世今生为什么大家都爱这么写Runtime.exec()从 Java 1.0 就有了它当年被设计出来的时候JDK 的 API 哲学和现在完全不一样官方文档也没有特别强调命令注入的问题。更糟糕的是早期大量 Java 入门教程、老旧项目代码甚至一些框架的底层实现里用的都是最简单粗暴的形态String cmd ping -c 3 ip; Process process Runtime.getRuntime().exec(cmd);或者更常见一点String cmd String.format(ffmpeg -i %s -f mp4 %s, inputFile, outputFile); Process process Runtime.getRuntime().exec(cmd);这种写法观感上确实简单逻辑也清晰——终端里怎么敲字符串里就怎么写回车一按命令就跑起来了。但问题恰恰出在这个“终端里怎么敲”上。当你把整个字符串交给exec(String)时JDK 在 Unix 平台上会默认把字符串丢给/bin/sh -c去解释执行在 Windows 平台上则要经过cmd.exe。这就意味着你以为你在执行一条命令实际上你是在执行一段 shell 脚本。参数边界、特殊字符、转义规则全部不由你控制而是交给 shell 来裁决。这里要先说清楚一个容易混淆的点Runtime.exec()其实有多个重载其中exec(String command)和exec(String[] cmdarray, String[] envp, File dir)在行为模型上是两回事。exec(String)会隐式调用系统的默认 shell 来解析整个字符串shell 会先做分词、变量替换、通配符展开再逐条执行最终解析出的命令。exec(String[])则是直接通过操作系统提供的进程创建接口把参数数组逐项传给目标程序中间没有 shell 这个翻译官。用生活化的类比来说exec(String)相当于你给管家递了一张纸条纸条上写着“去书房把书拿来然后顺便把窗台的花浇了”管家是按自然语言理解整句话的中间怎么断句、怎么理解你控制不了exec(String[])则是你按住管家的肩膀一字一顿地交代“去、书、房、拿、书。”每个词都是独立单元不允许被拆开重组更不允许被理解出额外的动作。所以拼字符串的真正问题不是“代码不够优雅”这种审美层面的小毛病而是你把用户输入当成自然语言的一部分毫无保留地交还给 shell 去断句。这就像你让一个陌生人替你对管家传话又完全不管陌生人说了什么。用户输入里一旦混着分号、竖线、反引号、$()、之类的 shell 元字符断句方式就可能完全失控。1.2 拼字符串的执行模型为什么说这是给自己埋雷我拆解一下拼字符串实际产生的执行链路你就能明白问题有多严重。当你写下exec(ping -c 3 ip)时如果用户传入的 ip 不是127.0.0.1而是127.0.0.1; touch /tmp/hacked那么传给 shell 的完整字符串就变成了ping -c 3 127.0.0.1; touch /tmp/hackedshell 会先执行ping -c 3 127.0.0.1然后无论第一条命令成功还是失败都会继续执行touch /tmp/hacked。这里的分号就是 shell 里的“命令分隔符”一条命令结束下一条命令开始。而如果用户用竖线|情况更复杂左边命令的输出会直接变成右边命令的输入可以直接把数据导走如果用户用反引号或者$()里面嵌套的命令还会先执行并且把执行结果拼回到原命令的字符串里。这意味着什么意味着用户输入可以被任意解释成新命令、管道、重定向、变量赋值这就是经典的命令注入。我不止一次在真实项目里看到过这种事故。某后台服务要根据用户上传的文件名去调用外部程序做格式转换代码大概是String cmd /usr/local/bin/converter filename; Process p Runtime.getRuntime().exec(cmd);这里同样有个问题文件名里如果包含空格比如my report.doc那么经过 shell 分词后会被拆成/usr/local/bin/converter my和report.doc两个参数外部程序拿到的文件名是错误的。更坏的情况是恶意用户提交report.doc; ls -la /etc服务端就会依次执行两条命令。在演示环境里ls -la /etc看起来无害但在真实环境里恶意用户完全可以替换成任何指令——下载木马、删除数据、反弹 shell全都干得出来。所以我接下来的思路很明确先把Runtime.exec()拼字符串会踩的坑系统梳理清楚然后介绍为什么ProcessBuilder是更好的替代方案最后给出完整的重构实操和排查经验。每个关键点我都会解释背后的原理因为只有理解了“为什么”你才能写出真正靠谱的代码而不是照葫芦画瓢。2. 核心细节解析与实操要点拼字符串的六个真实坑2.1 参数边界丢失你以为的一个参数在 shell 眼里是一堆参数第一个坑就是参数边界完全丢失。命令行程序解析参数全靠空格、引号这些分隔符但当你把用户输入拼进命令字符串后用户输入里的空格会被 shell 当成参数分隔符。举个例子假设你的程序要执行一个外部压缩工具代码写成String userInput archive report v2.zip; String cmd zip -r backup.zip userInput; Runtime.getRuntime().exec(cmd);你以为userInput是一个文件名但 shell 会把它拆成archive、report、v2.zip三个独立参数。外部工具收到三个文件参数大概率直接报错或者把不存在的文件当成输入。这种问题在正常使用中就会踩到根本不需要恶意用户参与。更常见的场景是路径里带空格。开发机上路径规规矩矩没有空格代码跑得很顺一到生产环境文件路径里多了个空格命令瞬间失效。我见过不少团队为了这种问题给路径手动加前后引号结果遇到路径里本身含引号的极端情况又炸了。加了引号只是把问题往后推了一层并没有从根上解决“让参数保持独立”这件事。2.2 特殊字符的连锁反应分号、竖线、重定向、变量替换第二个坑就是 shell 元字符的连锁反应。前面已经提到了分号这里再展开讲讲其它元字符分号;命令分隔符后面的命令无论前面成败都会执行。竖线|管道符会把前面命令的 stdout 接到后面命令的 stdin。与、或||逻辑控制符可以根据前面命令的返回值决定是否执行后面的命令。重定向、、把命令输出写入文件或从文件读取输入。反引号和$()命令替换先执行里面的命令把结果插入原位置。美元号$变量引用如果用户输入里写了$HOME、${PATH}会被 shell 展开成环境变量值。这些符号本身都是合法输入的一部分很多场景下用户完全可能无意间带上。比如业务系统里让用户填一个备注字段用户填的是“项目A 项目B”这个字符串如果被拼进命令后果可能就是两条命令都被执行了。我在测试某个文件处理服务时就遇到过用户上传的文件名里带括号和空格拼接字符串执行后shell 把文件名里的内容当成子命令执行当场报错日志里全是莫名其妙的片段。有人可能会说“我只要对用户输入做一下过滤把特殊字符删掉不就行了”这里要泼一盆冷水特殊字符的过滤是一个非常深的水潭。你需要考虑 shell 的完整语法规则还要考虑不同平台 shell 的差异而且只要漏掉一个符号防御就是零。你写一百行黑名单过滤也堵不住所有边界情况因为 shell 的解析规则本来就不是为“过滤输入”这种场景设计的。2.3 跨平台差异Windows 和 Unix 是两套完全不同的解析规则第三个坑是跨平台差异。cmd.exe和/bin/sh对特殊字符的处理规则完全不同。同一个字符串在 Linux 上可能正常执行在 Windows 上就行为诡异反过来也一样。我用一张表直接对比一下常见符号在两种平台下的表现符号Unix/bin/shWindowscmd.exe分号;命令分隔符后一条会继续执行基本是普通字符不是命令分隔符竖线 管道符连接两条命令与前一条成功才执行后一条支持但空格与引号处理逻辑有差异大于号重定向输出重定向输出但重定向目标解析规则更笨百分号%普通字符变量引用如%PATH%两个百分号包裹脱字符^普通字符转义符用来把特殊字符变成普通字符同一个输入dir list.txt type list.txt在 Windows 上是你想要的逻辑判断加重定向在 Unix 上执行时和同样会被 shell 解析但目录格式、命令名都不一样。如果你是在 Java 服务里拼一个跨平台命令要么写两套字符串逻辑要么写一堆兼容判断代码复杂度直接翻倍。但这还不是最麻烦的。最麻烦的是 Windows 的引号规则和 Unix 完全不同。在 Unix 里单引号包裹的字符串内部不做任何展开双引号内部会展开变量但还是保持整体参数在 Windows 里cmd.exe对双引号的处理有自己的逻辑7 个连排这种场景能让你 debug 到怀疑人生。我见过有人在执行路径带空格的程序时在 Windows 上反复尝试引号组合最后放弃了直接拷贝程序路径到一个无空格的临时目录。真的这种情况在真实开发里一点都不少见。2.4 转义地狱从代码里读懂“原本想传的参数”是一种奢求第四个坑是转义地狱。当你决定用拼字符串的方式你就必须考虑多层转义Java 字符串本身的转义、正则表达式的转义如果需要校验、shell 的转义、还有日志系统里的转义。这几层叠加起来代码根本没法读。举个例子你想通过 shell 执行一个带引号的命令Java 字符串里就得写成String cmd ls -l \/tmp/My File\;这还算能看懂。如果参数里本身有单引号和双引号你要在 shell 级别处理嵌套引用代码里就会堆满反斜杠和引号比如String cmd grep -r \ keyword \ /data/logs/ date /app.log;一旦 keyword 本身包含引号、反斜杠或者需要转义的空格你就必须知道 shell 在什么情况下会展开、什么情况下会保留。这种代码写出来的当下也许能跑但三个月后回来看的人很可能就是你自己只能看到一堆\和\\根本搞不清原本想传什么参数。我自己的体会是拼字符串跑命令的代码维护成本是“指数级”的。每加一个参数就要重新推演一遍 shell 的解析流程每次出问题都要从头到尾做一次转义校验。这种代码写起来一时爽排查起火现场时是真的想骂人。2.5 命令注入安全漏洞的入口第五个坑也是性质最严重的坑就是命令注入。前面我已经讲过原理了这里再深化一下。命令注入的本质是你的程序把不可信数据用户输入、外部接口返回的数据、消息队列消费的内容拼接进了交给 shell 执行的高特权命令里导致攻击者可以控制程序的执行逻辑。这里必须明确一个边界有些人觉得“我的系统是内网部署的用户都是自己人没人会故意输入恶意字符”但命令注入不只来自恶意攻击者。一个普通用户在输入框里填了一个带符号的名称比如设计部 市场部如果这个字符串被拼进了某个清理命令可能触发“后台执行”或“逻辑与”程序行为就不可控了。非恶意的误操作同样能引爆雷。安全领域有个基本共识所有外部输入都是不可信的。这个共识在哪里都适用。命令注入的修复方式其实并不复杂——不要用字符串拼接去构造命令行而是用参数数组的方式把命令和参数完全分离让用户输入永远只作为一个参数值传递而不是作为命令语法的一部分被解析。2.6 延迟报错与排查困难错的地方不在你能想到的位置第六个坑是延迟报错和排查困难。拼字符串执行命令时很多问题不会在你拼接的那一行代码上报出来而是要在外部程序内部才会爆发。比如参数分隔错了外部程序可能收到一堆乱七八糟的参数然后报一个“文件不存在”或者“参数不合法”的错误跟你原本想执行的操作八竿子打不着。你顺着日志去排查先怀疑外部程序再怀疑系统环境折腾半天才会想到哦原来是命令拼接时参数被拆碎了。更气人的是这种问题往往是偶发性的。用户这次输入的文件名没有空格一切正常下次输入的文件名带了空格瞬间崩了。你在本地复现时用了一个不带空格的路径又一切正常。这类“幽灵 bug”背后十有八九是拼接字符串导致的参数边界问题。3. 实操过程与核心环节实现用 ProcessBuilder 重构你的进程调用3.1 为什么是 ProcessBuilder 而不是别的Java 1.5 引入了ProcessBuilder它就是为了解决Runtime.exec()的一系列问题而设计的。核心设计理念就一句话命令和参数分离参数以列表或数组的形式传递不经过 shell 解析。ProcessBuilder的构造函数接收一个ListString或String[]第一个元素是命令本身后面的元素都是参数。传给目标程序的参数在操作系统层面就是一个个独立的字符串中间没有任何 shell 分词的过程。用户输入里的空格、引号、分号、竖线都只是参数里的一个普通字符不会被解释成语法元素。我用一个最简单的对比来说明// 错误写法拼字符串shell 参与解析 String cmd ping -c 3 ip; Process p Runtime.getRuntime().exec(cmd); // 正确写法参数数组保持每个参数的独立边界 ProcessBuilder pb new ProcessBuilder(ping, -c, 3, ip); Process p pb.start();第二段代码里即使 ip 的值是127.0.0.1; touch /tmp/hacked传给ping程序的也只是三个独立参数127.0.0.1; touch /tmp/hackedshell 不会去解释分号外部程序会把整个字符串当成一个 IP 参数去判断。恶意输入最多让业务逻辑报错却无法操纵命令执行。3.2 ProcessBuilder 核心 API 拆解ProcessBuilder常用的 API 不多但每一个都值得讲清楚command(ListString command)/command(String... command)设置要执行的命令和参数。这是最核心的方法参数必须保持独立不要拼成一个字符串。directory(File directory)设置子进程的工作目录。这个很实用可以避免你在命令里反复cd或者使用容易出错的相对路径。environment()返回一个MapString, String用来操作子进程的环境变量。注意这个 Map 是活的对象你往里放键值对子进程启动时就会带上。redirectErrorStream(boolean b)把子进程的 stderr 合并到 stdout。对于日志收集来说非常有用可以让你统一读一个流。redirectOutput(File file)/redirectError(File file)把子进程输出直接重定向到文件避免你手动消费流。start()启动子进程返回Process对象。还有一个细节需要说明ProcessBuilder默认不会经过 shell。这意味着如果你使用了 shell 特有的语法比如管道|、重定向、通配符*、环境变量引用$HOME在ProcessBuilder直通模式下是不会生效的。你给外部程序传一个*参数它收到的就是字面量*而不是展开后的文件名列表。这一点看起来是限制实际上是保护它的本质是让你明确知道自己要做什么。3.3 一个完整重构示例从拼字符串到 ProcessBuilder我拿一个实际场景给你演示重构过程。假设你的服务需要调用外部图像处理工具把用户上传的图片转换成指定尺寸原始代码是这样的String cmd convert inputPath -resize width x height outputPath; Process p Runtime.getRuntime().exec(cmd);这段代码的问题一眼可见inputPath、outputPath里只要带空格或者特殊字符命令就会碎掉width和height如果来自外部配置也存在被注入的风险。重构后的代码ProcessBuilder pb new ProcessBuilder( /usr/local/bin/convert, inputPath, -resize, width x height, outputPath ); pb.redirectErrorStream(true); Process p pb.start();重构之后inputPath、outputPath无论包含什么字符都只是作为一个整体参数传给convert程序。-resize是一个独立的参数后面的几何参数是另一个独立参数完全不受路径内容的影响。如果你还需要控制工作目录和超时可以继续加上pb.directory(new File(/tmp/convert-work)); Process p pb.start(); boolean finished p.waitFor(30, TimeUnit.SECONDS); if (!finished) { p.destroyForcibly(); throw new RuntimeException(图片转换超时); } int exitCode p.exitValue();这里把超时控制在 30 秒超时就直接强制结束子进程避免它变成不死的僵尸任务这一点在第四章还会继续展开。3.4 真的需要 shell 特性时怎么办受控的 shell 执行看到这里你可能会问那我的命令里确实需要管道、重定向、通配符这些 shell 特性该怎么办答案是可以明确使用 shell但要用“受控脚本 参数外传”的方式而不是字符串拼接。最稳妥的模式是这样命令主体写成一个脚本模板用户输入或者动态数据作为参数位置传入。比如你需要执行一条复杂的 shell 命令处理一个运行时确定的文件路径String script ls -l \$1\ wc -l \$1\; ProcessBuilder pb new ProcessBuilder( bash, -c, script, safe-script, filePath );这里bash -c后面的字符串是我们自己写的脚本模板safe-script是传给$0的参数名纯占位filePath作为$1传给脚本。用户输入的filePath即使包含分号、空格、引号也只会被当作$1这个位置参数的值不会被当成命令语法解析。脚本里的\$1\是双引号包裹的参数引用保证带空格的文件路径也能正确传递。这个模式的要点是所有变量都必须用位置参数$1、$2传入并且在脚本里引用参数时一定要加双引号。要避免把变量直接拼进 shell 脚本字符串里那样等于又绕回了字符串拼接的老路。4. 从“能跑”到“跑得稳”进程管理的完整姿势4.1 不读输出流等于给自己埋第二颗雷很多人在调用Process.waitFor()等待进程结束时会遇到“程序卡死”的诡异现象。这不是 Java 的问题而是操作系统管道缓冲区的机制在起作用。子进程的 stdout 和 stderr 是两根独立的管道管道缓冲区是有容量上限的通常是 4KB 到 64KB 不等取决于系统。当子进程输出大量数据时如果父进程不及时读取缓冲区满了之后子进程就会被操作系统阻塞在写操作上永远等不到缓冲区腾出空间自然也就无法退出。这时候你去waitFor()就会等到天荒地老。正确姿势是启动进程之后马上用两个独立的线程或线程池分别消费 stdout 和 stderr然后再去等待进程结束。Java 8 之后还可以用 CompletableFuture 做异步消费代码可以写得比较干净Process p pb.start(); CompletableFutureString stdoutFuture readStream(p.getInputStream()); CompletableFutureString stderrFuture readStream(p.getErrorStream()); boolean finished p.waitFor(10, TimeUnit.SECONDS); String stdout stdoutFuture.get(1, TimeUnit.SECONDS); String stderr stderrFuture.get(1, TimeUnit.SECONDS);读流的函数里用BufferedReader逐行处理即可。如果你的场景只是想把日志打进文件可以直接用pb.redirectOutput(File)和pb.redirectError(File)让系统直接把输出写进文件连消费流的线程都不用写。这其实是我最推荐的做法简单粗暴又不会出错。还有一点必须强调stdout 和 stderr 一定要分开读或者用redirectErrorStream(true)合并后只读一个流。千万不要只读 stdout 不读 stderr因为 stderr 一旦满了同样会阻塞子进程。踩过一次这个坑你就明白调试时看到的“程序没反应”往往是流缓冲区锁死的假象。4.2 超时与销毁别让子进程变成僵尸子进程的生命周期管理是拼字符串时代最容易忽略的环节。你调用了Runtime.exec()之后如果外部程序卡住不退出父进程也会跟着卡住。更麻烦的是子进程可能还会产生自己的子进程如果外部程序是一个脚本或者一个守护进程它启动的子进程不会因为主进程被销毁而自动结束。waitFor(long timeout, TimeUnit unit)是 Java 8 引入的方法可以给等待设置一个上限。超时之后先调用destroy()温和地请求终止过一小段时间还不退再调用destroyForcibly()强制终止。为了防止进程残留我一般会这样写if (!p.waitFor(15, TimeUnit.SECONDS)) { p.destroy(); if (!p.waitFor(5, TimeUnit.SECONDS)) { p.destroyForcibly(); p.waitFor(); } }注意一点在 Unix 上destroyForcibly()相当于发 SIGKILL杀掉的只是直接子进程本身不一定能杀掉它的子孙进程。如果外部程序启动的是一串进程树你还需要考虑用进程组的方式整体管理或者查一下目标进程的子进程 ID 再逐一清理。这个属于进阶话题但在生产环境里非常实用。4.3 环境变量与工作目录隐秘的正确性很多人拼字符串执行命令时会被“当前工作目录”坑得体无完肤。你在 IDE 里跑程序当前目录是模块根目录部署到服务器后当前目录变成了应用启动目录如果经过容器封装目录可能又是别的地方。命令里的相对路径在不同环境下的解析结果完全不同。ProcessBuilder.directory(File)可以显式指定工作目录这是解决相对路径问题的最干净方案。举个例子ProcessBuilder pb new ProcessBuilder(git, status); pb.directory(new File(/opt/repos/my-project)); pb.start();这样不管应用在哪个目录启动git status都会在指定仓库里执行。拼字符串时代这种操作你得在命令里写cd /opt/repos/my-project git status不仅绕而且一旦路径里带空格又得处理引号问题。环境变量的处理也是同样的思路。pb.environment()返回一个可变 Map你可以给子进程单独设置它需要的环境变量而完全不影响父进程的环境MapString, String env pb.environment(); env.put(JAVA_HOME, /usr/lib/jvm/default); env.put(LANG, zh_CN.UTF-8);如果你希望子进程继承父进程的大部分环境只是调整个别变量这个 Map 默认就是父进程环境的拷贝改起来非常方便。这在处理外部工具依赖特定 JDK 版本、特定 locale、特定 PATH 的场景里比拼命令字符串靠谱得多。4.4 退出码与错误信息每个非零退出码背后都有戏进程执行完退出码是判断成功与否的唯一标准。Unix 的惯例是 0 表示成功非 0 表示失败但不同外部程序的退出码含义各不相同有的用 1 表示普通业务失败有的用 2 表示参数错误。所以拿到非零退出码时一定要连同 stderr 的内容一起记录到日志否则排查问题的时候你只知道“失败了”不知道为什么失败。具体做法上我建议把退出码、stdout 尾部内容、stderr 完整内容、执行时长、命令本身注意脱敏都放进日志。这样线上出了问题看日志就能定位不需要重新跑一遍复现。拼字符串时代很多人不会刻意去收集这些信息出了问题只能干瞪眼而 ProcessBuilder 配合流处理做起来并不复杂一开始就把日志格式设计好后面能省很多事。5. 常见问题与排查技巧实录5.1 经典案例一命令明明在终端能跑代码里就是不行这是我被问到最多的问题。现象通常是这样的同一个命令复制到终端里执行完全正常但放进 Java 代码用Runtime.exec()执行要么报参数错误要么行为怪异要么干脆报“文件不存在”。排查思路很清楚先看是不是参数边界被 shell 拆碎了。终端里执行ls -l /tmp/my file.txtshell 也把它拆成三个参数但因为你自己在终端就是按空格拆的所以其实没问题。可如果代码里拼接时把用户输入my file.txt当成了一个参数shell 拆完后程序收到的却是两个参数。这个案例的解法就是换成ProcessBuilder的参数数组形式让参数保持边界。5.2 经典案例二命令执行了但没有任何输出排查思路先看是不是没消费输出流导致缓冲区阻塞然后看是不是外部程序把输出写到了 stderr 而不是 stdout。很多第三方工具把日志和错误都写到 stderr如果你只读了 stdout就会觉得“什么也没发生”。解决方案就是redirectErrorStream(true)把两个流合并或者分别消费两个流。拼字符串时代的代码往往只调用了waitFor()没认真处理流才会被这个问题坑到。5.3 经典案例三跨平台部署后行为不一致排查思路先对比两个平台上 shell 的解析规则差异。同样的命令字符串在 Unix 上可能依赖了/bin/sh的分词行为在 Windows 上由cmd.exe解析后就完全不一样了。另外还要注意一行路径分隔符Windows 用反斜杠Unix 用斜杠。如果你在代码里写死了路径部署到另一个平台就会炸。解决方案是在路径拼接时使用File.separator或者直接用Path、Paths工具类命令本身用参数数组不依赖 shell 分词。我把这些常见问题整理成了一张速查表方便你直接对照症状可能原因排查方向解决方案命令带空格路径执行失败参数被 shell 拆碎打印最终执行的命令参数看是否与预期一致改用 ProcessBuilder 参数数组程序卡死无响应输出流缓冲区满了未读检查是否消费 stdout 和 stderr异步读流或用 redirectOutput 重定向到文件偶发性参数错误字符串拼接转义不完整检查用户输入的特殊字符禁止拼字符串参数分离恶意输入导致异常行为命令注入审计所有拼接到命令的输入点不使用 shell 解析用户输入Windows 与 Linux 行为不同shell 解析规则差异对比两个平台的命令语法不依赖 shell用参数数组直通执行外部程序找不到文件相对路径解析不同确认当前工作目录用 ProcessBuilder.directory 显式指定5.4 排查手段别再用“打印一下”挣扎了很多人排查命令执行问题时习惯在代码里打印一堆字符串看得眼花缭乱也没找到问题。我分享几个高效的手段第一strace是排查这类问题的好帮手。用strace -f -e execve,wait4 java YourMainClass可以追踪你的 Java 进程到底调用了哪些系统调用尤其是execve的参数是什么样的。你一眼就能看清传给外部程序的实际参数根本不用猜。Windows 上可以用 Process Monitor 实现类似的效果。第二给ProcessBuilder的命令参数打结构化日志而不是打拼接好的字符串。最容易排查的方式是把命令列表打印出来System.out.println(CMD: pb.command());List的toString()会带上方括号和逗号你能直观看到每个参数的边界。如果参数里有空格也能清清楚楚不会把多个参数混成一团。第三临时用/bin/echo替换目标程序看参数是怎么传的。比如你想排查外部程序收到的参数先把命令换成echo执行完你就知道参数数组是什么样的了。这个技巧在排查第三方工具行为时特别实用不用真的去调外部程序。5.5 兜底方案旧代码一时改不完怎么办我知道不是所有项目都有机会立刻全面重构。如果是遗留系统老代码动起来牵扯面很大短期内改不完,那你也别再往里面加新的拼接字符串代码了。至少做到以下几点第一所有外部输入都做严格的白名单校验。比如 IP、端口、文件名先验证格式再拼接坚决拒绝含空格和;|$等字符的输入。第二命令本身写死不让用户输入影响命令内容。第三使用受控的 shell 脚本封装用户输入通过参数传进去而不是直接拼到命令里。第四在代码注释里标明 TODO用 ProcessBuilder 替换给后来的人留个清楚的标记。这不是完美方案但至少能降低风险面。凡是涉及安全的问题别总想着“等有空了再改”很多事故就是在“等有空了”的过程中发生的。我个人在实际操作中的体会是拼字符串跑命令这问题早发现早治疗越早改成参数数组模式后面流的泪越少。你可以把这条规则直接写进团队 code review 的检查清单里凡是见到Runtime.exec()拼字符串一律打回重写。听起来有点冷酷但我是真见过因为这行代码出事之后全组人熬夜排查、回滚、修复最后发现根因就是一行字符串拼接。那种憋屈我不想你再经历一遍。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑