XXE in OpenID: one bug to rule them all, or how I found a Remote Code Execution flaw affecting Facebook's servers

Hi, since I don't write much, let me first introduce myself. My name is Reginaldo Silva and I'm a brazilian computer engineer. These days I work mostly with information security, with a special interest in Web Application Security. I.E. if you let me, I'll find ways to hack into your site or application, hopefully before the bad guys do. You'll find a little more information about me going to my home page.

Today I want to share a tale about how I found a Remote Code Execution bug affecting Facebook. Like all good tales, the beginning was a long time ago (actually, just over a year, but I measure using Internet Time, so bear with me). If you find this interesting and want to hire me to do a security focused review or penetration testing in your own (or your company's) code, don't hesitate to send me an email at "my_first_name@this_domain.com".

September 22nd, 2012 was a very special day for me, because it was the day I found a XML External Entity Expansion bug affecting the part of Drupal that handled OpenID. XXEs are very nice. They allow you to read any files on the filesystem, make arbitrary network connections, and just for the kicks you can also DoS the server with the billion laughs attack.

I was so naive at the time that I didn't even bother to check if anyone else was vulnerable. I reported it immediately. I wanted to start putting CVEs on my resume as soon as possible, and this would be the first (it eventually got CVE-2012-4554 assigned to it). Only five days later it occurred to me that OpenID was pretty heavily used and so maybe other places were vulnerable as well. I decided to check the StackOverflow login form. Indeed, it was vulnerable to the whole thing (file reading and all).

Then I decided to try to find OpenID handling code running inside Google's servers. I wasn't able to read files or open network connections, but both App Engine and Blogger were vulnerable to DoS. This is how I got my first bounty from Google, by the way. It was a US$ 500 bounty.

After reporting the bug to Google, I ran some more tests and eventually noticed that the bug I had in my hands was affecting a lot of implementations. I won't enumerate the libraries here, but let me just say that this single bug affected, in one way or another, libraries implemented in Java, C#, PHP, Ruby, Python, Perl, and then more... The only reason I'm not publishing the PoC here is that there are a lot of servers who are still vulnerable out there. Of course, the people who know about security will just read OpenID and XXE and then write an exploit in about 5 minutes, but I digress.

So after contacting (or trying to contact) every OpenID library author out there, I decided to write to the member-only security list hosted at the OpenID foundation an email titled "One bug to rule them all: many implementations of OpenID are vulnerable to XXE" to share my findings. I figured most library authors would be members of that list and so patches would be released for everyone very soon. I was right, but only partially.

The persistent readers who are still with me by now are thinking: what does a Facebook Remote Code Execution bug has to do with all this? Well, I knew Facebook allowed OpenID login in the past. However, when I first found the OpenID bug in 2012 I couldn't find any endpoint that would allow me to enter an arbitrary OpenID URL. From a Google search I knew that in the past you could do something like https://www.facebook.com/openid/consumer_helper.php?openid.mode=checkid_setup&user_claimed_id=YOUR_CLAIMED_ID_HERE&context=link&request_id=0&no_extensions=false&third_party_login=false, but now the consumer_helper.php endpoint is gone. So for more than a year I thought Facebook was not vulnerable at all, until one day I was testing Facebook's Forgot your password? functionality and saw a request to https://www.facebook.com/openid/receiver.php.

That's when I began to suspect that Facebook was indeed vulnerable to that same XXE I had found out more than a year ago. I had to work a lot to confirm this suspicion, though. Long story short, when you forget your password, one of the ways you can prove to Facebook that you own an @gmail.com account is to log into your Gmail and authorize Facebook to get your basic information (such as email and name). The way this works is you're actually logging into Facebook using your Gmail account, and this login happens over OpenID. So far, so good, but this is where I got stuck. I knew that, for my bug to work, the OpenID Relying Party (RP - Facebook) has to make a Yadis discovery request to an OpenID Provider (OP) under the attacker's control. Let's say http://www.ubercomp.com/. Then my malicious OP will send a response with the rogue XML that will then be parsed by the RP, and the XXE attack will work.

Since the initial OpenID request (a redirect from Facebook to Google) happens without my intervention, there was no place for me to actually enter an URL under my control that was my OpenID identifier and have Facebook send a Yadis Discover request to that URL. So I thought the bug would not be triggered at all, unless I could somehow get Google to send Facebook a malicious XML, which was very unlikely. Fortunately, I was wrong. After a more careful reading of the OpenID 2.0 Specification, I found this nice gem in session 11.2 - Verifying Discovered Information:

"If the Claimed Identifier was not previously discovered by the Relying Party (the "openid.identity" in the request was "http://specs.openid.net/auth/2.0/identifier_select" or a different Identifier, or if the OP is sending an unsolicited positive assertion), the Relying Party MUST perform discovery on the Claimed Identifier in the response to make sure that the OP is authorized to make assertions about the Claimed Identifier".

I checked and, indeed, the openid.identity in the request was http://specs.openid.net/auth/2.0/identifier_select. This is a very common practice, actually. So indeed after a few minutes I was able to make a request to https://www.facebook.com/openid/receiver.php that caused Facebook to perform a Yadis discovery on a URL under my control, and the response to that request would contain malicious XML. I knew I had a XXE because when I told Facebook's server to open /dev/random, the response would never come and eventually a request killer would kick in after a few minutes. But I still couldn't read any file contents. I tried everything on the XXE bag of tricks (including weird combinations involving parameter entities, but nothing. I then realized I had a subtle bug in my exploit. Fixed that, and then...

$ bash exploit.sh
* About to connect() to www.facebook.com port 80 (#0)
*   Trying 31.13.75.1... connected
* Connected to www.facebook.com (31.13.75.1) port 80 (#0)
> GET /openid/receiver.php?provider_id=1010459756371
    &context=account_recovery&protocol=http&request_id=1
    &openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0
    &openid.mode=id_res&openid.op_endpoint=...(redacted)... HTTP/1.1
> Host: www.facebook.com
> Accept: */*
> User-Agent: Chrome
>
< HTTP/1.1 200 OK
< Cache-Control: private, no-cache, no-store, must-revalidate
< Expires: Sat, 01 Jan 2000 00:00:00 GMT
< P3P: CP="Facebook does not have a P3P policy. Learn why here:
    http://fb.me/p3p"
< Pragma: no-cache
< X-Content-Type-Options: nosniff
< X-Frame-Options: DENY
< X-XRDS-Location: http://www.facebook.com/openid/xrds.php
< X-XSS-Protection: 0
< Set-Cookie: datr=...(redacted)...; expires=Thu, 19-Nov-2015 15:34:24 GMT; 
    path=/; domain=.facebook.com; httponly
< Set-Cookie: reg_ext_ref=deleted; expires=Thu, 01-Jan-1970 00:00:01 GMT;
    path=/; domain=.facebook.com
< Set-Cookie: reg_fb_gate=http%3A%2F%2Fwww.facebook.com%2Fopenid%2Freceiver.php
    %3Fprovider_id%3D1010459756371%26context%3Daccount_recovery%26protocol%3Dhttp
    %26request_id%3D1%26openid.ns%3Dhttp%253A%252F%252Fspecs.openid.net%252Fauth
    %252F2.0%26openid.mode%3Did_res%26openid.op_endpoint%3D...(redacted)...;
    path=/; domain=.facebook.com
< Set-Cookie: reg_fb_ref=http%3A%2F%2Fwww.facebook.com%2Fopenid%2Freceiver.php
    %3Fprovider_id%3D1010459756371%26context%3Daccount_recovery%26protocol%3Dhttp
    %26request_id%3D1%26openid.ns%3Dhttp%253A%252F%252Fspecs.openid.net%252Fauth
    %252F2.0%26openid.mode%3Did_res%26openid.op_endpoint%3D...(redacted)...;
    path=/; domain=.facebook.com
< Content-Type: text/html; charset=utf-8
< X-FB-Debug: ...(redacted)...
< Date: Tue, 19 Nov 2013 15:34:24 GMT
< Transfer-Encoding: chunked
< Connection: keep-alive
<
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<script>
function envFlush(a) {
    function b(c) {
        for (var d in a) c[d] = a[d];
    }
    if (window.requireLazy) {
        window.requireLazy(['Env'], b);
    } else {
        Env = window.Env || {};
        b(Env);
    }
}
envFlush({
    "user": "0"
});
<title>Facebook</title>
<script src="http://static.ak.fbcdn.net/rsrc.php/v2/yR/r/Bx6hq_79BTx.js" crossorigin="anonymous"></script>
<script type="text/javascript">window.Bootloader &&
  Bootloader.done(["ASVup"]);</script>
</head>

<body class="Locale_en_US">
<script type="text/javascript">
Bootloader.setResourceMap({
    "\/2NZV": {
        "type": "js",
        "crossOrigin": 1,
        "src": "http:\/\/static.ak.fbcdn.net\/rsrc.php\/v2\/yo\/r\/CAz6i9Uu16e.js"
    },
    "GduTW": {
        "type": "js",
        "crossOrigin": 1,
        "src": "http:\/\/static.ak.fbcdn.net\/rsrc.php\/v2\/yu\/r\/aGXWJInaxrx.js"
    }
});
</script>
<script type="text/javascript">
require("InitialJSLoader").loadOnDOMContentReady(["GduTW","\/2NZV"]);
</script>
<script type="text/javascript">
Bootloader.configurePage([]);
Bootloader.done([]);


require("InitialJSLoader").handleServerJS({
    "require": [
        ["OnloadHooks"],
        ["lowerDomain"]
    ]
});

onloadRegister_DEPRECATED(function () {
    openid_submit_response({
        "__ar": 1,
        "error": 1428005,
        "errorSummary": "Error while processing response",
        "errorDescription": {
            "__html": " \
There was an error while processing the OpenID response. \
No matching endpoint found after discovering http:\/\/www.ubercomp.com\/...(redacted)... \
<br \/><br \/> OP Endpoint mismatch. Expected http:\/\/www.ubercomp.com\/...(redacted)..., \
got http:\/\/www.ubercomp.com\/...(REDACTED).../?x=\
root:x:0:0:root:\/root:\/bin\/bash\n \
bin:x:1:1:bin:\/bin:\/sbin\/nologin\n \
daemon:x:2:2:daemon:\/sbin:\/sbin\/nologin\n \
adm:x:3:4:adm:\/var\/adm:\/sbin\/nologin\n \
lp:x:4:7:lp:\/var\/spool\/lpd:\/sbin\/nologin\n \
sync:x:5:0:sync:\/sbin:\/bin\/sync\n \
shutdown:x:6:0:shutdown:\/sbin:\/sbin\/shutdown\n \
halt:x:7:0:halt:\/sbin:\/sbin\/halt\n \
mail:x:8:12:mail:\/var\/spool\/mail:\/sbin\/nologin\n \
uucp:x:10:14:uucp:\/var\/spool\/uucp:\/sbin\/nologin\n \
operator:x:11:0:operator:\/root:\/sbin\/nologin\n \
games:x:12:100:games:\/usr\/games:\/sbin\/nologin\n \
gopher:x:13:30:gopher:\/var\/gopher:\/sbin\/nologin\n \
ftp:x:14:50:FTP User:\/var\/ftp:\/sbin\/nologin\n \
nobody:x:99:99:Nobody:\/:\/sbin\/nologin\n \
dbus:x:81:81:System message bus:\/:\/sbin\/nologin\n \
...(REDACTED)..."
        },
        "payload": null,
        "bootloadable": {},
        "ixData": []
    }, 1)
});
</script>
</body>
</html>
* Connection #0 to host www.facebook.com left intact
* Closing connection #0

That's right, the response contained Facebook's /etc/passwd. Now we were going somewhere. By then I knew I had found the keys to the kingdom. After all, having the ability to read (almost) any file and open arbitrary network connections through the point of view of the Facebook server, and which doesn't go through any kind of proxy was surely something Facebook wanted to avoid at any cost. But I wanted more. I wanted to escalate this to a full Remote Execution.

A lot of bug bounty programs around the web have a rule that I think is very sensible: whenever you find a bug, don't linger on messing around. Report the bug right away and the security team will consider the worst case scenario and pay accordingly. However, I didn't have much experience with the security team at Facebook and didn't know if they would consider my bug as a Remote Code Execution or not. I Since I didn't want to cause the wrong impressions, I decided I would report the bug right away, ask for permission to try to escalate it to a RCE and then work on it while it was being fixed. I figured that would be ok because most bugs take a long time to be processed, and so I had plenty of time to try to escalate to an RCE while still keeping the nice imaginary white hat I have on my head. So after writing the bug report I decided to go out and have lunch, and the plan was to continue working when I came back.

However, I was wrong again. Since this was a very critical bug, when I got back home from lunch, a quick fix was already in place. Less than two hours after the initial report was sent. Needless to say, I was very impressed and disappointed at the same time, but since I knew just how I would escalate that attack to a Remote Code Execution bug, I decided to tell the security team what I'd do to escalate my access and trust them to be honest when they tested to see if the attack I had in my mind worked or not. I'm glad I did that. After a few back and forth emails, the security team confirmed that my attack was sound and that I had indeed found a RCE affecting their servers.

So this is how the first high impact bug I ever found was the entry point for an attack that probably got one of the highest payouts of any web security bug bounty program. Plus, and more importantly, I get to brag I broke into Facebook... Nice, huh? Oh, by the way, the Facebook security team wrote a post to tell their side of the story.

Join the discussion on Hacker News.

Timeline

All timestamps are in GMT. I omitted a few unimportant interactions about the acknowledgements page and such.