Webcam Hacking

The story of how I gained unauthorized Camera access on iOS and macOS 

This post is a technical walkthrough of how I discovered several zero-day bugs in Safari during my hunt to hack the iOS/MacOS camera. This project resulted in me gaining unauthorized access to:   

This post contains the real BugPoC links used to report the bugs to Apple. Download Safari 13.0.4 if you want to check out the live demos that Apple used to reproduce the issues.

Background

The goal of this project is to hack the iOS/macOS webcam. All

other vulnerabilities uncovered during this hunt are just bonus bugs.

Before I jump in, I want to start with a quote from an old colleague of mine - "Bug hunting is all about finding assumptions in software and violating those assumptions to see what happens." That is precisely what we are going to do today. We are going to dive into the murky depths of Safari and hammer the browser with obscure corner cases until we uncover weird behavior quirks. Once we collect enough quirks, we can tie them together into a full kill chain.

The camera security model in iOS and macOS is pretty intense. In a nutshell, each app must be explicitly granted camera/microphone permission, which is handled by the OS via a standard alert box.

But there is an exception to this rule. Apple's own apps get camera access for free. So Mobile Safari can technically access the camera without asking. Furthermore, new web technologies such as the MediaDevices Web API (commonly used in WebRTC transmissions) allow websites to utilize Safari's permission to access the camera directly. Great for web-based video conferencing apps such as Skype or Zoom. But... this new web-based camera tech undermines the OS's native camera security model. This paper titled "A Study of WebRTC Security" puts this conundrum nicely:

"If the user chooses a suitable browser which they know can trust, then all WebRTC communication can be considered 'secure' [...] In other words, the level of trust provided to the user by WebRTC is directly influenced by the user's trust in the browser." 

So the question all iOS/macOS users must ask themselves... how much do you trust Safari?

Quick side note before we get going. Encryption is a mandatory feature of WebRTC. This is enforced by only exposing the mediaDevices API's when your website is deemed to be in a "Secure Context." This means that even awesome UXSS bugs like this, this, or this are unable to get camera access. The camera is protected deep in the guts of Safari.

Some quick research shows that Safari keeps track of permission settings on a per-website basis to let websites access sensitive content such as GPS location or camera “without always asking for permission.” Basically, you can allow Skype to access your camera whenever it wants because you trust Skype. You can see which websites you currently trust in Safari > Preferences > Websites.

This feature essentially required Safari to reimplement the OS⟺App security model from scratch in a Browser⟺Website context. The natural question that this model brings up - how well does Safari keep track of these websites?

We are beginning to form the attack plan - if we can somehow trick Safari into thinking our evil website is in the "secure context" of a trusted website, we can leverage Safari's camera permission to access the webcam via the mediaDevices API.

Keeping track of websites

In order for Safari to use your website settings, it of course needs to know which websites you are currently viewing. Abstractly, this is actually a fundamental responsibility of all browsers and is core to upholding Same-Origin-Policy. Trying to trick browsers into failing this responsibility is key to any UXSS or SOP bypass exploit. 

But after a few minutes of playing around, I noticed something strange - Safari seemed to not use origins to keep track of your "currently open websites" at all. In fact, their method of keeping track of websites here was quite bizarre.

In the above example, all 4 browser windows have unique origins. However, Safari only seems to think that 1 website is open. After some more experimentation, I deduced that Safari was likely running a Generic URI Syntax parser against all open windows to get the URIs' hostnames, then doing some extra parsing on those. Specifically, Safari seems to remove any "www." in the start of hostnames. This was super interesting to me because as we all know, parsing URLs is hard.

After doing some light hostname fuzzing, I noticed a weird quirk. Hostnames with a dash (-) and period (.) touching each other (i.e "-." or ".-") were completely invisible to Safari's "currently open websites" feature. I was unable to immediately figure out how to use this quirk to get to the camera but still an interesting observation. Sort of unnerving that "https://foo-.evil.com" would be missing from this menu. (CVE-2020-9787) Working demo on BugPoC: bugpoc.com/poc#bp-Ay2ea1QE password laidMouse24. Note that the demo will only work if viewed using Safari 13.0.4. BugPoC was super useful here for demonstration purposes because it lets you instantly create your own custom subdomain for hosting HTML code. Nifty!

A more important observation was that the URL's scheme is completely ignored. This is problamatic because some schemes don’t contain a meaningful hostname at all such as file:, javascript:, or data:. Other schemes contain the hostname within an embedded URI ~ what the Tangled Web dubbed, "Encapsulating Pseudo Protocols" ~ such as view-source: or blob:. Being able to simply grab the string between "://" and "/" and treat it as a valid hostname was a bad assumption by Safari. In retrospect, Safari should have kept a whitelist of schemes that can be parsed like this such as http:, https:, ws:, etc. (CVE-2020-3852)

Its now time to find a way to exploit this faulty assumption. Let's start playing with some pseudo-protocols.

The usual suspects

The goal here is to create a URI that, when parsed using the Generic URI Syntax, as defined in RFC 3986, produces an arbitrary hostname that is trusted by the victim. Easy, right?

The obvious places to start - javascript:, data:, and about:. (tl;dr- none worked. if you don't like your stories to have dead ends, I recommend you jump to the next section)

javascript:

I was really hopeful about this one. javascript://skype.com should do the trick, right? The hostname, as seen by Safari in this context, should be skype.com. Wrong. It turns out that when Safari attempts to open this URL, the page actually loads “about:blank" and the content is sent directly to the JavaScript engine without touching the URL bar. In other words, window.location.href doesn’t actually equal “javascript://skype.com" when you view the page. I tried a handful of ways to halt page loading and trick Safari into giving me this href, however none were successful. Moving on...

data:

Next up is data:. The goal is to create a URI that is valid when parsed by both RFC 2397 (data:) and RFC 3986 (old-school authority URIs). A Polyglot URL, if you will. After some testing, I came up with this: data://,@skype.com. I opened this page using the standard window.open() and checked Safari preferences. Safari thinks skype.com is currently open. Success! 

But we have a problem - while technically a valid data: URI, the mimetype is “//“ which is not recognizable by Safari (or any browser) and the spec dictates that the default mimetype is text/plain. This means that “data://,@skype.com?<script>alert(1)</script>” just produces a harmless text file. While Safari might be confused about what its looking at, the file itself can’t do anything too evil without JavaScript execution. And because Safari follows the modern best practice of giving every data: URI its own unique origin, we cannot dynamically populate the document with JavaScript post rendering. 

w = open('data://,@skype.com');

w.document.write('<script>alert(1)</script>');

> SecurityError

Bummer. This isolated origin protection was actually developed to prevent the new document from mucking with its parent, not vice versa. But it technically thwarts this type of attack too. I tried a handful of different ways into tricking Safari into rendering my text/plain data: URI as HTML but none were successful. (perhaps one day if the type anchor tag attribute ever gets implemented...)

 

After taking a closer look at how Safari was internally parsing this URL, I decided to give window.history a try. Perhaps I could start with an HTML data: URI, then alter the "pathname" to be “//skype.com“ without any actual page loading or navigation (and thus no mimetype updating).

Unfortunately for us, the RFC gods (sort-of) saw this coming and explicitly prohibit using history.pushState or history.replaceState to change origins. Note that the spec references the true algorithmic definition of “origin”, not the classic scheme/host/port tuple. In this case (a data: URI) doing a history.replaceState would change the origin from Opaque Origin A to Opaque Origin B. No bueno.

history.replaceState('','','data://skype.com')

> SecurityError

about:

Last up on my shortlist was about:. This one seemed surprisingly fruitful. about://skype.com was actually accepted by Safari! (Doing something like this in Chrome errors). However I was again unable to dynamically populate the document. 

w = open('about://skype.com');

w.document.write('<script>alert(1)</script>');

> SecurityError

It seems that Safari only allows about:blank and about:srcdoc to inherit the origin of the opener. I found an old WebKit bug report where they considered loosening up this restriction but no dice. For now, about://skype.com gets a unique opaque origin just like data:. This also means that history.pushState shenanigans will also be blocked. 

file: to the rescue

The next scheme I started messing around with was file:. This scheme does not contain a meaningful hostname, right? Digging deep into the RFC rabbit hole I actually stumbled upon a bizarre variation of the file: URI that does contain a hostname. This type of URI actually specifies a remote server, similar to FTP, however no retrieval mechanism for files stored on a remote machine is defined by this specification. After searching the interwebs for a while, I was unable to find any user agent that actually supports this obscure URI type.

file://host.example.com/Share/path/to/file.txt

Pretty funky. Just out of curiosity, I checked how Safari internally parses a normal file URI.

As expected, the hostname is blank. I decided to go ahead and specify a host using JavaScript just to see what happens.

What the hell. The page actually accepted this URI as valid and reloaded the same content. Which means I just changed the document.domain using this really dumb trick. (CVE-2020-3885)

 

Sure enough, Safari thinks we are on skype.com and I can load some evil JavaScript. Camera, Microphone, and Screen Sharing are all compromised when you open my local HTML file. Bonus - Safari also seems to use this lazy hostname parsing method to fill autocomplete on passwords. So I can steal plaintext passwords if you accept autocomplete.

file://exploit.html

<script>

if (location.host != 'google.com'){

    location.host = 'google.com';

}

else {

    alert(document.domain);

}

</script>

So the hunt is over, right? Wrong. This hack required the victim to open a local HTML file. We can do better than that. Plus, it does not work on iOS because local files downloaded via Mobile Safari get shown in a preview-style embedded view without a JavaScript engine. Let’s keep looking. 

Bonus Bug: auto-downloads

In an attempt to make the above file:// hack more realistic, I decided to look into how we could trick Safari into automatically downloading the malicious HTML file from our website. (of course, downloading the file is only half the battle. the victim still needs to open it)

I remembered reading about a simple referer header spoofing bug in Edge on Broken Browser a while ago. It turns out that a similar technique could be used to easily bypass the auto-download prevention in Safari! Just open a trusted website in a popup then re-use the popup for a download link. (CVE-2020-9784 & CVE-2020-3887)

open('https://dropbox.com','foo');

setTimeout(function(){open('/file.exe','foo');},1000)

I used the BugPoC Mock Endpoint feature to make a URL with Content-Disposition attachment response header containing a small text file for the demo. BugPoC Demo: bugpoc.com/poc#bp-t9C660OJ password calmOkapi20. Note that the demo will only work if viewed using Safari 13.0.4. 

 

Ok, back to the camera hunt. 

blob: weirdness

The blob: encapsulating pseudo protocol is an interesting tool. It lets you directly access a file tucked away in browser memory using a random identifier. This lets you easily reference files that you create on the fly. These type of URIs are typically used for images and videos, however an interesting feature is that they actually allow you to specify the mimetype yourself. Safari will even try its best to render anything it can, so you can make an HTML document and open it up in a new tab.

blob = new Blob(['<h1>hello, world!</h1>'], {type: 'text/html'});

url = URL.createObjectURL(blob);

open(url,'_blank');

Blob: spec​ dictates the algorithm that browsers must use to generate these neat URIs. tl;dr-

blob: [origin of creating document] / [random UUID]

But there is some subtlety in the algorithm that is worth calling out - the origin is serialized into a string. But what happens if the origin making the blob URI has no meaningful serialization? Like a null effective domain document with an opaque origin. Quick reminder that opaque origins (like the ones given to data:text/html,foo or about://foo) always get serialized into the string “null” and this serialization is 1-way. Meaning that, internally, Safari treats data:text/html,foo and data:text/html,bar as having two unique origins even though they both become “null” when stringified. Well spec says it's up to the browser to figure that one out -

"If serialized is 'null', set it to an implementation-defined value." 

Gotta love ambiguity in spec. Let's see how Safari handles this odd case. We can start on a data: URI to give ourself an opaque origin, data:text/html,foobar, then create a blob: URI.

Interesting. So the serialized origin in the blob: URI is literally the string “null” just like it is when you print it the console. Because this serialization is by definition 1-way, I was curious to how Safari would know which opaque origin is allowed to open the URI.

 

After some experimenting, I determined that Safari does, in fact, enforce SOP here so it must be using the random UUID to help find the true creating origin. Opening this blob URI using JavaScript from the creating document worked as expected and the new document was able to inherit the opaque origin just as the RFC gods always wanted. Attempting to use JavaScript to open this URI from a different opaque origin resulted in an error, also as expected.

 

But then I noticed something weird... manually typing this URL into the Safari address bar gave me the origin “://

This appeared to be some “blank” origin that Safari thought was appropriate to give me. This is not the opaque origin that created this document. In fact, according to the origin serialization standards, this is not an opaque origin at all. This is the result of a blank scheme, blank host, and blank port being run through the algorithm. (CVE-2020-3864) Going forward, I will refer to this bizarre origin as the magical "blank" origin. After more playing around, I discovered that this null-blob-URI inherits the origin of any opener! But the catch is that Safari checks to make sure the opener is allowed to open the URL first (and thus upholding SOP). But this messes up when the opener isn’t a normal document. Working Demo on BugPoC: bugpoc.com/poc#bp-wkIedjRe password laidFrog49. Note that the demo will only work if viewed using Safari 13.0.4. 

 

Accessing this URI from a bookmark caused some really bizarre behavior. 

You can see that Safari attempts to grant this null-blob-URI the https://example.com origin, however fails because it doesn’t actually have any document stored in that memory location (hence the WebKitBlobResource error).

But Safari is apparently able to find the document when the opener had the “blank” :// origin. Evidently I stumbled upon a bizarre origin that is equivalent to null in some contexts but not technically opaque. I just needed to type the null-blob-URI into the address bar manually to get there.

So now we need to find a way to programmatically get to this magic "blank" origin. Basically just a method of simulating a manual address bar entry. Luckily there is an API for that! The location.replace API replaces the current resource as if it was the URL originally navigated to. But remember, Safari will check to make sure you have permission to view this URL so this location replacing needs to come from the same document that created the URL.

From an opaque origin:

blob = new Blob(['<h1>hello, world!</h1>'], {type: 'text/html'});

url = URL.createObjectURL(blob);

location.replace(url);

Nice. We successfully hopped from a data: URI with an opaque origin to a blob: URI with a blank origin. Now what?

Rewriting History

Back when we tried to use window.history to muck with data: URIs, we were unsuccessful because changing the pathname inadvertently changed opaque origins too (behavior explicitly disallowed by the history spec). Let's see if anything is different now that we are no longer on an opaque origin.

 

Quick reminder of what we look like at this point:

Let's try something pretty aggressive:

history.pushState('','','blob://skype.com');

> SecurityError

Bummer. It appears that Safari correctly realizes that the above pushState would send us to a new origin. But then I noticed something really strange. The below pushStates are allowed!

history.pushState('','','blob://');

history.pushState('','','skype.com'); 

location.href

> blob://skype.com

What is going on here? Why was this behavior disallowed when we tried in one fell swoop but totally fine when we break it up? Well the steps to determine a blob: URI's origin can be found here. My best guess is that Safari correctly thinks the origin of blob://skype.com is a new opaque origin (step 3 from spec) but for some reason considers the origin of blob:// to be :// or what we have been calling the "blank" origin (step 2 from spec). (fixed as a part of CVE-2020-3864) 

Because our current origin is also :// this pushState is permitted. The next pushState just changes the pathname so Safari doesn't see a problem with it. And like that, we now have the location.href of blob://skype.com! A quick check on Safari preferences shows skype.com as a currently open website. Are we finally done now?

Not quite. While we do have JavaScript execution in a document recognized by Safari as skype.com, it's not a "Secure Context" so we don't get any of the fun APIs like mediaDevices.

Sure enough...

We can, however, do auto-downloads, auto-popups, and autocomplete plaintext passwords. Good stuff, but not a webcam. Let’s keep trying.

Secure Context without TLS

Let's look closely at what exactly a "Secure Context" really is.

“A secure context is a Window or Worker for which there is reasonable confidence that the content has been delivered securely (via HTTPS/TLS), and for which the potential for communication with contexts that are not secure is limited. Many Web APIs and features are accessible only in a secure context. The primary goal of secure contexts is to prevent man-in-the-middle attackers from accessing powerful APIs that could further compromise the victim of an attack.” 

Ok, this is an understandable requirement to use WebRTC. It would be pretty scary if anybody on your WiFi could access your webcam (assuming you are visiting an HTTP website that you've previously trusted). Now it's time to find a new bug to circumvent this requirement.

After digging deep into the Secure Context spec, I noticed a contradiction - browsers are permitted to  also treat file: URLs as trustworthy because it's "convenient for developers building an application before deploying it to the public."

 

I was curious to how Safari implemented this exception, so I started exploring what makes file: URLs unique. The SOP rules around this protocol have been hotly debated for some time, and the origin of these URLs is browser dependent. Modern versions of Safari give each file a separate opaque origin, and after some experimentation, I discovered that Safari lazily treats all documents with an opaque origin to be a secure context. (CVE-2020-3865) This is a really big oversight because it is easy for an HTTP site to create an opaque origin document. One obvious way is a sandboxed iframe-

<iframe src="/" sandbox></iframe>

The only issue here is that spec says “for a page to have a secure context, it and all the pages along its parent and opener chain must have been delivered securely.” Which means that an opaque origin document embedded in an HTTP website is ultimately considered insecure.

Lucky for us, Safari seemed to ignore the "opener chain" part of the spec and only check the parents for secureness. This lets us simply open a popup from within the sandboxed iframe to make a secure context window.

<iframe srcdoc="<script>open('/')</script>" sandbox="allow-scripts allow-popups"></iframe>

Side bar- another, perhaps easier, way for a MiTM attacker to give an HTTP website an opaque origin would be to simply add the CSP sandbox header to the response. 

But how does that help us over on the blob://skype.com world? Well it doesn’t, really. This URL is fake, in that it was never delivered to us over the internet. Its a frankensteined monstrosity that we made ourself using wonky history.pushStates and blank origins. Doing this sandboxed-iframe-popup trick won’t work for us because doing a window.open(‘/‘) will make Safari try to genuinely load blob://skype.com... which we all know doesn’t really exist. 

So we need to think of a way to open a popup with 1) the blob://skype.com URI,  2) an opaque origin, and 3) arbitrary JavaScript. All without telling Safari to try to genuinely load anything.

I remembered reading on Broken Browser that Edge would get confused when an inherited origin document did a document.write(). Optimistically, I tried messing around with that.

Turns out that, in Safari, a document will actually spread its location.href if it performs a document.write to an inherited origin document. 

Great! This accomplishes part 1 and 3 of what we are trying to do here. We can now make a popup with the blob://skype.com URI and arbitrary JavaScript. Now we just need to figure out how to give it an opaque origin.

 

Quick reminder of what we look like at this point-​

The tricky part here is that document.write() is only allowed when the popup has the same origin as us (to abide by SOP). So we need to somehow perform a document.write() then null-out its origin.

Here is the plan- Let's start on our blank-origin blob://skype.com URI and create a regular iframe to about:blank. Then perform a document.write() to give this iframe the blob://skype.com href. Then we dynamically add the sandbox attribute to this iframe and do the sandboxed-iframe-popup trick from before. This should spread the blob://skype.com href from parent -> iframe -> popup if we do it right.

But there is a problem with our plan - iframe spec says that dynamically added sandbox flags only get applied after the iframe navigates to a new page. 

This is a tough one. Remember that our URL is fake at this point. Doing any frame navigation will break the illusion and ask Safari to genuinely try to fetch/load. Even something as innocuous as location.reload() will cause Safari to realize it's on a fake URL and produce an error.

So we need to come up with a way to force a frame navigation without Safari actually changing the URL or page content.

Then it hit me - what if the navigation fails due to something out of our control? What if Safari genuinely tries to do the fetch/load but just can't complete it. What if we try to navigate our iframe to a real URL with the X-Frame-Options header in its response?

document.getElementById('theiframe').contentWindow.location = 'https://google.com';

> Refused to display 'https://www.google.com/' in a  frame because it set 'X-Frame-Options' to 'SAMEORIGIN'.

Sure enough, that counts as a real frame navigation! The dynamically added sandbox flags are now applied but the iframe URL and content are untouched.

document.getElementById('theiframe').contentDocument

> Sandbox access violation: Blocked a frame at "://" from accessing a frame at "null".

We now have a sandboxed iframe with the blob://skype.com href and arbitrary JavaScript content. A simple window.open() popup is the final step to glory. Side note- the BugPoC Mock Endpoint feature was again useful for demonstration purposes to make the X-Frame-Options endpoint. BugPoC Demo: bugpoc.com/poc#bp-2ONzjAW6 password blatantAnt90. Note that the demo will only work if viewed using Safari 13.0.4. 

Tying it all together

Well, bug hunter, we finally did it. We started on a normal HTTP website and ended up on a bastardized blob URI in a Secure Context. Here is a quick summary of how we did it-

  1. Open evil HTTP website

  2. HTTP website becomes a data: URI

  3. data: URI becomes a blob: URI (with magic blank origin)

  4. Manipulate window.history (in 2 parts!)

  5. Create an about:blank iframe and document.write to it

  6. Dynamically give this iframe the sandbox attribute

  7. Attempt an impossible frame navigation using X-Frame-Options

  8. From within the iframe, window.open a new popup and document.write to it

  9. Profit

From this popup, we can use the mediaDevices Web API to access the webcam (front or rear), microphone, screen sharing (macOS only) and much more! To get the "evil code" (mediaDevices JavaScript) on the popup, we need to play an insane game of hot potato. Here is the final diagram:

And finally, a screen recording of what this attack would look like in the wild:

*victim in prerecorded demo has previously trusted skype.com

Working demo on BugPoC: bugpoc.com/poc#bp-HHAQuUYC password: blahWrasse59. Note that the demo will only work if viewed using Safari 13.0.4.