Today's Safari update addresses a rather serious issue (CVE-2011-0167) that I found and reported to Apple. This issue allows Javascript from any website to jump into the local zone and access any files accessible to the user running Safari. This bug actually exists in WebKit, so other browsers could be affected. This is why it's good when browsers do what Chrome did, and heavily limit what can be done by local HTML. . Check out the related reading at the end of this post for more on that.
Here's a quick video of the bug in action. Only HTML and JavaScript are required to accomplish this.
Bug description:
The problem is that when internally generated pages (such as error pages) are loaded, they are loaded from the local "file:" zone, but the window's location can still be set by all scripts that have a reference to the window, such as an attacker's website.
Why remote-to-local matters:
Safari normally blocks loading of local content from remote sites for a ton of reasons. One reason is that local content in Safari has a fair bit of power*, including the ability to read any file accessible to the current user. This can be accomplished a number of ways. The easiest is using XMLHttpRequest()s in order to read files by "file:///" URL. Once loaded, the content can be sent anywhere.
*To read about the many other things that local HTML can do, check out the related reading links below.
How remote-to-local restrictions normally work:
You can see remote-to-local restrictions in action by trying to load the following HTML from a web server in Safari:
Click on the link and you will get the following error in the Javascript console:
Sometimes remote-to-remote turns into remote-to-local:
I noticed something interesting occurs when trying to load a web page that doesn't exist, cannot be reached, or any other action that results in an internally generated page being displayed. The page is loaded from a file on disk and the current window location points at a "file:///" URL. For example, execute the following javascript from a web page:
var x = window.open("http://www.thisisamadeupdomain4321.com:9876","bogusWin")
After the page load fails, the Safari error page is displayed:
Because of how Safari displays its error pages, we now have a window in the local "file:///" zone, but triggered by a load of an "http://" URL.
Exploiting this behavior:
The vulnerability exists in the fact that this window, with full access to the "file:///" URLs, can be navigated by its parent window, when it should be blocked by a security check. We can navigate this child window by setting x.window.location, and can set it to any URL including those in "file:///". All we need in order to exploit it is some malicious HTML to point at using a "file:///" URL.
There are numerous ways to get a local path to point at remotely originating payload content. It's a little silly, but here's what I did. This proved to be reliable enough for a proof of concept that works with the default configuration of Safari on Mac OS X:
1. When you launch the proof of concept by clicking the submit button on thing.html, we start by mounting a disk image with our local payload
2. If you have the default Safari settings, Mac OS X will mount the disk image in /Volumes/poc
3. We then attempt to load http://127.0.0.1:7/ in a new window. Since port 7 (tcp echo) is on the port block list, an error page in the "file://" zone is loaded
4. We then tell the window that is displaying this error page that it should load a different file, /Volumes/poc/ds.html. The fact that we can do this is the bug itself
5. Safari loads the Javascript in ds.html in the local zone. The Javascript opens a SQLite3 file, /var/db/dslocal/indices/Default/index, because it contains the names of local users
6. We use Javascript to rip some usersnames from the SQLite3 database file using a regular expression, and then attempt to access the target file in each of their accounts
7. File contents (if we have the right user) and usernames are communicated back to the parent window using postMessage (direct communications between the windows)
Some questions and answers about this bug:
1. Does the pop-up blocker stop this bug?
No.
2. What if I changed my preferences so that 'Open "safe" files' is not selected? Am I safe against this?
No. The slightly more technical explanation for this answer is that my proof of concept is using a disk image just to get malicious content at a known local path. There are a lot of other ways to accomplish this task. For example, my original proof of concept used an "ftp://" URL, and triggered Finder.app to automatically mount the site in /Volumes. I felt like setting up a public FTP server was a bit of a hassle so I switched to this easy and portable version. There are also less obvious ways to do this, including methods that involve no user-visible clues that a malicious file is now accessible through a "file://" path.
3. Will the Mac OS X file quarantine feature stop this exploit?
No. While we're using a disk image that came from Safari and this file is therefore quarantined, we are not launching our payload. Instead we are loading it from an active HTML document that has already been loaded into the "file://" zone.
4. Your proof of concept did not work for me. Does that mean I am not vulnerable?
No. In the proof of concept I'm basically grepping a binary SQLite3 database file with Javascript. I am going to assume this isn't a terribly portable way of grabbing usernames. There are a bunch of other ways we could get the username of our visitor. For example, my original version of this proof of concept used a rather simple method of gathering usernames by parsing monthly.out. The monthly.out file is generated once a month and contains active usernames as part of system accounting. I got annoyed that my proof of concept wouldn't work on a fresh system, so I switched to the new method. Here's how I was grabbing usernames from the monthly.out file:
function process_log(monthlog) {
var gathered_users=new Array();
var line_pos = 0;
var in_block = 0;
var month_lines = monthlog.split("\n");
for (line_pos=0;line_pos<=month_lines.length-1;line_pos++) {
if (month_lines[line_pos] == "Doing login accounting:") {
in_block=1;
} else if (in_block==1 && month_lines[line_pos] == "") {
in_block=0;
} else if (in_block) {
var user_line = month_lines[line_pos].split(/[\t ]/);
if (user_line[1].length>0 && user_line[1] != "total") {
// user_line[1] is a potential username
gathered_users[user_line[1]]=1;
}
}
}
var victim = "";
for (victim in gathered_users) {
do_something_bad(victim);
}
}
[Note: I apologize about how gross that Javascript is, but I figured someone might have a use for it. You can replace the proof of concept i posted to use this one pretty easily.]
5. Will this work on Windows?
Good question. I suspect that it will, and that using a UNC path you might be able to point at remote files for your payload. However I have not had a chance to test this. If you have a test system and a moment to port this to Windows, I'd appreciate it if you would post a comment. I'd also like to know if you can launch programs from this local window by using document.location or by loading a JAR in the "file://" zone. That could be interesting.
6. Is Safari the only browser affected?
No. Safari is the only browser that this proof of concept was designed to work with, but the bug itself is in WebKit and affects other WebKit-based browsers. If you are testing your WebKit-based browser, remember that if you use a different URL scheme for things like these error pages, you may need to modify the test case to attempt to access resources using that scheme instead of "file://".
The proof-of-concept:
You should be able to uudecode this into a zip, that is ready to be dropped in a path
in your web server.
Thanks:
I would like to thank Cedric of the Apple Product Security team for the responsive and responsible handling of this issue.
I would also like to thank the Google Security Team for putting in the effort to harden WebKit against many of these issues and protect their users.
Also, thanks cstone.
Related reading:
Excellent work by the Google/Chrome guys:
This bug:
I found this bug a while ago -- before the following blog post was written, but I think people who find this kind of bug interesting would also be interested in lcamtuf's blog post: