
有人问“服务端能不能检测出某个请求来自curl | bash”直接回答并不容易因为这个问题的答案分两个层次一是“能不能认出这是 curl”二是“能不能判断 curl 的输出被交给了 bash”。本文的 Curlbash_detect 项目就是围绕这个方向做的一个概念验证我会从原理、实现、测试到工程落地说清楚。1.curl | bash安装模式的工作原理与检测价值在 Linux 和 macOS 的开发环境里见过下面这样的安装命令很常见curl -fsSL https://example.com/install.sh | bash这一条命令的含义可以拆开看curl负责向服务器发起 HTTP GET 请求并把返回内容写到标准输出|管道把 curl 的标准输出接到bash的标准输入bash 再把读到的内容当作 shell 脚本逐行执行。之所以很多项目选择这种分发方式是因为它足够直接用户不需要先下载文件、再手动赋权限、最后执行一条命令就能把远端脚本跑起来。Rust 的rustup、Node 的nvm、Docker 的安装脚本等当年都提供过类似的安装入口。软件的安装文档里写一行curl ... | sh比写“请下载 installer 后运行”少很多解释成本。但这种模式的争议同样明显脚本执行前用户基本看不到脚本内容。即使 curl 是-fsSL这种“安静模式”服务端到底返回了什么中间有没有被劫持或篡改用户很难提前验证。对脚本提供方来说也面临同样的信息盲区不同的用户访问安装脚本时有人可能直接下载保存有人可能直接管道执行服务端能不能把它们区分开直接关系到日志分析、异常流量识别和安装行为统计。所以 Curlbash_detect 这类 PoC 的核心价值不是去改变curl | bash的安全问题而是先解决一个观测问题服务端收到请求时如何通过 HTTP 可见的信息推断出“这个请求更像是浏览器访问还是 curl 命令甚至是正在被 bash 执行的脚本请求”。这里先给出一个关键判断纯 HTTP 请求头层面服务端只能看到 curl 的请求无法直接看到 shell 管道。真正判断“bash 是否执行了脚本”需要借助响应内容联动或者脚本执行后的回调上报。后面会展开这套思路。2. HTTP 请求中curl 和浏览器到底差在哪里要判断请求来自 curl最直观的入口是 HTTP 请求头。拿一次真实的curl -fsSL https://example.com/install.sh来说服务端能收到的请求头大概长这样GET /install.sh HTTP/1.1 Host: example.com User-Agent: curl/8.5.0 Accept: */*对比现代 Chrome 浏览器访问同一个 URL请求头会多出一大批 curl 不会默认携带的字段GET /install.sh HTTP/1.1 Host: example.com User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ... Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8 Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q0.9,en;q0.8 Cache-Control: no-cache Connection: keep-alive Sec-Fetch-Dest: document Sec-Fetch-Mode: navigate Sec-Fetch-Site: none Sec-Fetch-User: ?1 Sec-CH-UA: Google Chrome;v127, ... Upgrade-Insecure-Requests: 1把两者放在一起可以提炼出几个有区分度的特征。特征维度默认 curl 请求现代浏览器请求User-Agentcurl/8.x.xMozilla/5.0 ...长字符串Accept*/*text/html,application/xhtmlxml,...Accept-Encoding默认不携带除非指定--compressed通常携带gzip, deflate, brAccept-Language默认不携带通常携带如zh-CN,zh;q0.9Sec-Fetch-Dest无存在值为document、script等Sec-Fetch-Mode无存在值为navigate、no-cors等Sec-CH-UA无Chromium 系浏览器存在Cookie取决于是否显式传入同域下通常自动携带另外curl 默认使用 HTTP/1.1行为上偏保守浏览器则更主动地支持压缩、缓存、预检等能力。这些差异组合起来就是一个可以被程序解析的“指纹”。但要注意上述对比只是“默认情况”。curl 几乎每一个请求头都可以通过参数改写比如-A改 User-Agent-H Accept: text/html改 Accept--compressed开启压缩。所以单看某一项字段很容易误判更适合的做法是给多项特征打分再判断整体相似度。3. 为什么单靠 User-Agent 不够很多想做同类判断的人第一个想法是判断 User-Agent 是否以curl/开头。这个逻辑能覆盖大部分场景但远远不够。第一User-Agent 完全由客户端控制。只要执行命令的人愿意完全可以用curl -A Mozilla/5.0 ...伪装成浏览器。对于自动化脚本、爬虫、攻击性请求来说改一个 User-Agent 是成本最低的操作所以如果安全策略把 UA 当作强校验依据很容易被绕过。第二不是所有“使用 curl 协议特征”的请求都会带上curl/字样。Python 的requests库 UA 是python-requests/2.x.xNode 的axios在不同环境下 UA 也不固定Go 的net/http默认 UA 可能是空的。如果把检测范围限定在 UA 前缀这类客户端都会被漏掉。第三请求头的组合关系比单个字段更稳定。curl 默认发的Accept: */*其实是一个很低调但也很有辨识度的特征配合没有Sec-Fetch-*、没有Accept-Encoding、没有Accept-Language整体组合能大幅降低误判概率。浏览器再如何自定义 UA它发起普通页面导航时通常还是会带上Sec-Fetch-*系列头这就是组合判断的意义。所以比较稳妥的设计思路是用多项特征加权评分而不是用一个布尔条件做人肉判断。评分结果给一个“置信度”再结合访问路径、响应类型、后续回调做最终结论。这也就回答了文章开头的问题Curlbash_detect 这类检测的本质是“请求来源启发式判断”它给出的不是一个不可绕过的安全证明而是一个可供日志、风控和统计分析使用的置信度信号。4. Curlbash_detect 的启发式评分模型下面是这个 PoC 里用到的评分模型设计比较适合作为第一版实现。评分项分为两组加分项让请求“更像是 curl”减分项让它“更像是浏览器”。最后得分越高越有可能是 curl再结合请求的 URL 是安装脚本路径就能进一步推断它可能被用于curl | bash安装流程。特征分值判断逻辑User-Agent 以curl/开头30强特征Accept 等于*/*15curl 默认值浏览器很少这样未携带 Accept-Encoding10curl 默认不发压缩协商Accept-Encoding 未包含 gzip5浏览器几乎总会带缺少 Sec-Fetch-* 系列头8现代浏览器导航请求会带缺少 Sec-CH-UA5Chromium 系浏览器会带携带完整 Sec-Fetch-* 系列-20强浏览器特征直接拉低得分携带 Accept-Language-10浏览器普遍带curl 默认不带携带 Cookie-5浏览器自动带curl 需手工指定把得分阈值设为 50 分达到或超过则判定为“疑似 curl”。这个阈值不是拍脑袋定的默认 curl 请求基本能拿到 30 15 10 5 8 5 73 分远超阈值而现代浏览器命中多个减分项得分通常会低于 0。中间地带留给伪装的请求和自定义客户端。实现时需要注意几个细节所有检测都要在请求头层面完成不要读取请求体避免因为读取 body 阻塞连接。对缺失字段的判断要使用“字段是否为空”而不是“字段是否有值”因为有些客户端会发送空头。评分结果不要只输出布尔值同时输出理由列表方便排障。检测逻辑要独立成函数方便在 Node、Python 等不同语言里复用。5. Node.js 实现一个最小可运行的检测服务先给出一个基于 Node.js 原生http模块的实现不依赖任何第三方包适合直接复制测试。文件路径server.jsconst http require(http); function detectCurlRequest(req) { const headers req.headers; const ua headers[user-agent] || ; let score 0; const reasons []; if (/^curl\//.test(ua)) { score 30; reasons.push(User-Agent 以 curl/ 开头); } const accept headers[accept] || ; if (accept */*) { score 15; reasons.push(Accept 为 */*符合 curl 默认值); } const acceptEncoding headers[accept-encoding] || ; if (!acceptEncoding) { score 10; reasons.push(未携带 Accept-Encoding); } else if (acceptEncoding.indexOf(gzip) -1) { score 5; reasons.push(Accept-Encoding 未包含 gzip); } const secFetchHeaders [sec-fetch-dest, sec-fetch-mode, sec-fetch-site, sec-fetch-user]; const presentCount secFetchHeaders.filter((name) headers[name]).length; if (presentCount secFetchHeaders.length) { score - 20; reasons.push(携带完整 Sec-Fetch 头更像浏览器); } else { score 8; reasons.push(缺少 Sec-Fetch 头不是现代浏览器导航请求); } if (!headers[sec-ch-ua]) { score 5; reasons.push(无 Sec-CH-UA不是 Chromium 系浏览器); } if (headers[accept-language]) { score - 10; reasons.push(携带 Accept-Language浏览器特征更明显); } if (headers[cookie]) { score - 5; reasons.push(携带 Cookie浏览器特征更明显); } return { score: Math.max(0, score), isCurl: score 50, reasons, }; } const server http.createServer((req, res) { const result detectCurlRequest(req); if (result.isCurl) { res.writeHead(200, { Content-Type: application/x-sh, X-Curlbash-Detect: yes, }); res.end(echo 检测到 curl 访问得分${result.score};\necho 来源${req.headers[user-agent]};\n); } else { res.writeHead(200, { Content-Type: text/html; charsetutf-8, X-Curlbash-Detect: no, }); res.end(h2install.sh/h2p请使用 curl 访问获取脚本或直接在页面查看脚本源码。/p); } }); server.listen(3000, () { console.log(Curlbash_detect 服务已启动http://localhost:3000/install.sh); });启动服务node server.js然后在另一个终端分别测试curl http://localhost:3000/install.sh预期输出是 shell 脚本内容因为这次访问的 User-Agent 默认就是curl/8.x.x得分会高于阈值。如果直接使用默认 curl 触发并把响应管道交给 bashcurl -s http://localhost:3000/install.sh | bash终端会显示检测到 curl 访问得分73 来源curl/8.5.0这说明服务端已经在响应头X-Curlbash-Detect: yes里标记了这次的 curl 身份。如果换成浏览器打开http://localhost:3000/install.sh返回内容则是 HTML 页面从X-Curlbash-Detect: no可以看到判定结果不同。服务端只根据请求头不同就返回了两种完全不同的内容载体这就是检测结果在业务中的直接应用。6. Python Flask 实现与测试验证如果团队技术栈偏 Python可以用 Flask 写一个等价版本。相比 Node 原生实现Flask 的代码更贴近 Web 业务开发方便后续加上路由、日志和数据库。文件路径app.pyfrom flask import Flask, jsonify, request app Flask(__name__) def detect_curl_request(): ua request.headers.get(User-Agent, ) score 0 reasons [] if ua.startswith(curl/): score 30 reasons.append(User-Agent 以 curl/ 开头) accept request.headers.get(Accept, ) if accept */*: score 15 reasons.append(Accept 为 */*) accept_encoding request.headers.get(Accept-Encoding, ) if not accept_encoding: score 10 reasons.append(未携带 Accept-Encoding) elif gzip not in accept_encoding: score 5 reasons.append(Accept-Encoding 未包含 gzip) sec_headers [Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site] present [h for h in sec_headers if request.headers.get(h)] if len(present) len(sec_headers): score - 20 reasons.append(携带完整 Sec-Fetch 头更像浏览器) else: score 8 reasons.append(缺少 Sec-Fetch 头) if not request.headers.get(Sec-CH-UA): score 5 reasons.append(无 Sec-CH-UA) if request.headers.get(Accept-Language): score - 10 reasons.append(携带 Accept-Language) if request.headers.get(Cookie): score - 5 reasons.append(携带 Cookie) return { score: max(0, score), is_curl: score 50, reasons: reasons, } app.route(/install.sh) def install_script(): result detect_curl_request() if result[is_curl]: script ( echo Python 版检测到 curl 访问;\n fecho 得分: {result[score]};\n echo 如果看到这行说明脚本正被 shell 执行;\n ) return script, 200, {Content-Type: application/x-sh} html ( h2install.sh/h2 p当前访问来自浏览器。/p p如果想要安装脚本请在终端执行 curl 命令。/p ) return html, 200, {Content-Type: text/html; charsetutf-8} if __name__ __main__: app.run(host0.0.0.0, port5000)启动服务前先安装依赖pip install flask启动python app.py验证方式一模拟真实安装流程curl -s http://127.0.0.1:5000/install.sh这时候服务端返回的是 shell 脚本而不是 HTML。如果再把它接到 bashcurl -s http://127.0.0.1:5000/install.sh | bash终端输出Python 版检测到 curl 访问 得分: 73 如果看到这行说明脚本正被 shell 执行验证方式二模拟用户手工下载后查看curl -s http://127.0.0.1:5000/install.sh -o /tmp/install.sh cat /tmp/install.sh此时文件内容就是服务端返回的脚本文本但因为 curl 后面没有接 bash服务端不会收到任何后续信号。这一点正好说明了“检测到 curl”和“确认 bash 执行”是两回事。验证方式三用浏览器打开http://127.0.0.1:5000/install.sh浏览器会渲染出 HTML 页面。如果打开开发者工具查看网络请求响应头里会带着Content-Type: text/html; charsetutf-8和 curl 请求得到的application/x-sh形成鲜明对比。下面是同一服务在不同请求方式下的判定结果参考请求方式判定结果主要命中特征curl http://127.0.0.1:5000/install.shis_curltrueUA、Accept、无 Sec-Fetchcurl -A Mozilla/5.0 ... http://127.0.0.1:5000/install.sh属于不确定地带UA 像浏览器但其余特征仍像 curl浏览器地址栏直接访问is_curlfalse完整 Sec-Fetch、Accept-Language、CookiePython requests 访问得分为较低置信度UA 非 curl但缺少浏览器头第二行那种“-A伪装 UA”的情况用单一 UA 规则一定会漏判但评分模型基于其他头组合仍然可能给出较高分。这也说明组合特征比单个字段更抗伪造。7. 进一步确认“bash 是否真的执行”的联动方案如果产品需求不只是识别 curl还想知道“脚本是不是真的被 bash 执行了”就需要把检测从请求头阶段推进到响应内容联动阶段。一个可行的做法是在服务端返回的 shell 脚本末尾追加一个自报平安的回调请求。脚本执行到末尾时会发起一次新的 HTTP 请求携带一个一次性 token服务端收到回调后就可以把前一次GET /install.sh的访问记录和这次回调关联起来从而确认“脚本被实际执行”。示意的脚本内容大致如下#!/bin/bash echo 安装开始... # 实际安装逻辑 # 执行完成后回传状态 curl -s https://your-server.com/callback?tokenxxxxxxxx /dev/null服务端记录关联的时候可以在真正的安装脚本请求里生成一个短期的随机 token并写到日志中脚本内容里把这个 token 以环境变量或者 URL 参数的方式带出去。需要注意的是这种做法会引入新的隐私和治理问题在脚本里埋回调意味着用户执行安装命令时运行环境会悄悄向服务器发起额外请求。工程上如果要做必须至少满足两个前提第一回调对用户可见比如在脚本里加注释说明第二提供跳过机制避免因为回调失败中断安装流程。还有一种需要谨慎对待的方向是直接在响应脚本里做“探测”来反向确认执行环境。比如返回一段包含特殊 shell 语法的脚本用 bash 执行和用 sh 执行的结果并不完全一致服务端通过回调差异来判断命令解释器。这个思路适合 PoC但不适合直接进入生产因为会导致脚本可读性明显下降也会增加被滥用和恶意探测的风险。所以更稳妥的工程判断是请求头评分负责“高置信度识别 curl”回调关联负责“确认执行链路”两层结合才能接近文章标题里curl – bash这个场景的完整识别。大多数情况下如果只是想给日志打标签或做访问路径分析第一层已经够用。8. 常见问题与排查思路下面是在实际环境中使用这类检测时容易遇到的问题。问题现象可能原因排查方式解决方案浏览器访问被判定为 curl浏览器扩展、安全插件修改了请求头在浏览器开发者工具里查看实际请求头降低对 UA 特征的依赖增加浏览器特征校验curl 通过-A伪装 UA 后判定失败攻击者或自动化脚本主动改头对比完整请求头而不是只看 UA加入 Accept、Sec-Fetch、Accept-Encoding 组合评分同一请求在不同网络环境判定结果不同企业代理、CDN 边缘节点修改了请求头对比客户端实际发送的请求头与服务端接收的请求头在 CDN 层保留原始请求头并透传安装脚本被下载保存后再执行服务端无法感知curl -o或下载器只下载不执行查看服务端日志中是否有回调请求使用回调 token 机制而不是依赖请求头Python requests 访问也被划到 curl 类别两者都缺少浏览器特征头观察 UA 字段如果业务上需要把python-requests单独归类检测逻辑偶发报错影响安装脚本服务评分函数里访问了不存在的请求头字段打印完整 headers 结构所有取值都用get并设置默认空字符串排查时有一个原则先看请求头原文再做判断。很多误判不是检测逻辑错了而是假设的请求头集合和真实请求头不一致。遇到判定结果不符合预期第一步永远是打印服务端看到的 headers而不是改代码继续猜。另外如果服务前面有 Nginx 或 CDN要注意请求头有没有被标准化。比如 Nginx 可能会自行添加X-Forwarded-ForCDN 可能会统一调整Accept-Encoding这些都会轻微影响评分但通常不致命。真正致命的场景是 WAF 或安全产品把外部请求的原始头清洗掉了导致服务端看到的信息不完整。这时候应该优先在接入层配套做好原始头透传而不是在应用层强行造轮子。9. 工程实践建议与安全边界Curlbash_detect 是一个思路清晰的 PoC但要从示例走向生产必须考虑下面几个工程层面问题。第一检测结果不要直接当作鉴权凭据。HTTP 请求头可以被任意客户端构造所以哪怕评分模型做得再细也只能作为“置信度”而不是“身份证明”。如果某个安装脚本只允许 curl 访问、拒绝浏览器访问攻击者完全可以手动构造一个 curl 特征请求头绕过限制。更合理的设计是把检测结果写入结构化日志用于数据分析和异常趋势监控真正的高价值操作再叠加 token、签名、来源 IP 等其他校验手段。第二给检测逻辑一个版本号。请求头特征会随着浏览器和 curl 版本迭代而变化。今天 Chromium 浏览器会携带Sec-CH-UA未来可能出现新的标准头。给检测函数加上detector_version字段并在日志里完整记录原始 request headers未来调整评分模型时才能回看历史数据、验证变更效果。第三注意安装脚本服务的响应策略。检测到 curl 时返回application/x-sh检测到浏览器时返回 HTML 文档这种方案体验很好但要注意缓存问题。浏览器可能会因为Content-Type变化缓存错误内容建议在响应头里加Cache-Control: no-store或按 User-Agent 维度的 Vary 头避免 CDN 把脚本内容和 HTML 混用。第四日志字段要尽量结构化。建议把检测结果记录成下面的字段{ path: /install.sh, source: curl|bash, is_curl: true, score: 73, reasons: [UA, Accept, NoSecFetch], detector_version: v1 }这样后续做报表、告警或训练模型都有足够的数据支撑。安全边界上也要明确本文讨论的所有方法都应该用于自己拥有或获授权管理的服务上用来改善安装脚本的分发体验和日志分析。不要把检测逻辑当成绕过他人系统访问控制的工具更不要基于伪造请求头去模拟或者干扰第三方服务。安全检测的第一原则永远是合法授权、最小必要、可解释。10. 后续学习方向Curlbash_detect 这类基于 HTTP 头的检测虽然实现成本低但天花板也比较明显请求头可以被构造UA 可以被伪造。如果想进一步降低误判可以把下面几个方向串起来。TLS 指纹是近年比较成熟的补充手段。不同的 HTTP 客户端在 TLS 握手时呈现的特征并不相同curl、浏览器、Python requests 的 TLS ClientHello 结构存在差异通过 JA3/JA4 指纹可以在不依赖请求头的情况下识别客户端类型。代价是需要较强的网络基础设施支持不适合所有团队自建。还有一种思路是做行为关联如果某个 IP 先访问install.sh几秒后又访问了回调 URL那么即使第一次访问的请求头不够典型也能通过行为链推断出脚本被实际执行。对于只负责维护安装脚本的团队我的建议是先从本文的 Node.js 或 Flask 版本开始部署到测试环境跑通“curl 返回脚本、浏览器返回文档、日志记录评分结果”这套闭环。先把观测做到位再谈更复杂的指纹识别和风控策略。检测的本质不是拦截而是让服务端对“谁在访问、以什么方式访问”这件事有更清晰的认知。