From 0ea09dc5512b1005385a109f37be83496891571a Mon Sep 17 00:00:00 2001 From: vichhka-git Date: Tue, 24 Feb 2026 17:04:40 +0700 Subject: [PATCH 1/6] feat: Add Python 3 host environment execution support Allows users to run scripts using the host's Python 3 environment, enabling the use of external pip packages. Adds a UI dropdown to select between Jython and Python 3. Retains full API parity with the legacy ScriptEnvironment.py. --- AGENTS.md | 151 ++++++++++++ build.gradle | 5 + resources/turbo_intruder.py | 449 ++++++++++++++++++++++++++++++++++++ src/Python3Runner.kt | 418 +++++++++++++++++++++++++++++++++ src/fast-http.kt | 51 +++- 5 files changed, 1070 insertions(+), 4 deletions(-) create mode 100644 AGENTS.md create mode 100644 resources/turbo_intruder.py create mode 100644 src/Python3Runner.kt diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..247270b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,151 @@ +# TURBO INTRUDER — PROJECT KNOWLEDGE BASE + +**Generated:** 2026-02-24 +**Commit:** 0923fef +**Branch:** master +**Version:** 1.62 + +## OVERVIEW + +Burp Suite extension for high-speed HTTP fuzzing. Custom hand-coded HTTP/1.1 and HTTP/2 stacks via raw sockets, controlled via Python attack scripts (Jython 2.7 or external Python 3). Complements Burp Intruder for bulk/billion-request attacks, race conditions, and timing-based vulnerabilities. + +## STRUCTURE + +``` +turbo-intruder/ +├── src/ # Kotlin + Java source (compiled together) +│ ├── fast-http.kt # UI (TurboIntruderFrame), Jython eval, CLI entry point +│ ├── BurpExtender.kt # Extension registration (legacy + Montoya API) +│ ├── RequestEngine.kt # Abstract base — queue, gate, diff, retry logic +│ ├── BurpRequestEngine.kt # Engine using Burp's native HTTP stack +│ ├── ThreadedRequestEngine.kt # Raw socket HTTP/1.1 engine (main workhorse) +│ ├── HTTP2RequestEngine.kt # Custom H2 multiplexed engine +│ ├── SpikeEngine.kt # Single-packet attack engine (race conditions) +│ ├── Python3Runner.kt # External Python 3 subprocess via JSON-RPC +│ ├── H2Connection.kt # HTTP/2 connection + stream management +│ ├── HeaderEncoder.kt # HPACK header compression +│ ├── Frame.kt / Stream.kt # HTTP/2 framing primitives +│ └── burp/ # Java utilities (TurboLib, Floodgate, Bruteforce, Utils) +├── resources/ +│ ├── ScriptEnvironment.py # Python environment injected before user scripts +│ └── examples/ # 29 Python attack scripts (canonical usage references) +├── build.gradle # Gradle build — produces turbo-intruder-all.jar (fat jar) +├── decorators.md # Python decorator API reference +└── README.md +``` + +## WHERE TO LOOK + +| Task | Location | +|------|----------| +| Extension registration / hotkey / menus | `src/BurpExtender.kt` | +| UI, script editor, attack toggle | `src/fast-http.kt` → `TurboIntruderFrame` | +| Jython script execution | `src/fast-http.kt` → `evalJython()` | +| Python 3 subprocess bridge | `src/Python3Runner.kt` | +| Queue/gate/diff/retry base logic | `src/RequestEngine.kt` | +| Raw socket HTTP/1.1 implementation | `src/ThreadedRequestEngine.kt` | +| Burp-native HTTP engine | `src/BurpRequestEngine.kt` | +| HTTP/2 multiplexed engine | `src/HTTP2RequestEngine.kt` | +| Single-packet race attack | `src/SpikeEngine.kt` + `src/SpikeConnection.kt` | +| Python script patterns / examples | `resources/examples/*.py` | +| Python API decorators | `decorators.md` | +| Build configuration | `build.gradle` | +| Burp API interfaces (legacy) | `src/burp/*.java` | + +## ENGINE TYPES + +Three request engines — chosen in Python scripts via engine parameter: + +| Constant | Class | Transport | +|----------|-------|-----------| +| `Engine.BURP` (1) | `BurpRequestEngine` | Burp's native HTTP stack | +| `Engine.THREADED` (2) | `ThreadedRequestEngine` | Raw sockets, HTTP/1.1 | +| `Engine.BURP2` (4) | `BurpRequestEngine` | Burp HTTP/2 batch (race) | +| `Engine.HTTP2` (3) | `HTTP2RequestEngine` | Custom H2 multiplexed | +| `Engine.SPIKE` | `SpikeEngine` | Single-packet via Burp HTTP/2 | + +Default engine when unspecified: `Engine.THREADED`. + +## PYTHON SCRIPT CONTRACT + +Every attack script must implement exactly two functions: + +```python +def queueRequests(target, wordlists): + engine = RequestEngine(endpoint=target.endpoint, + callback=handleResponse) + # Inject payloads via %s in request template: + engine.queue(target.req, payload) + engine.openGate('name') # for race attacks + +def handleResponse(req, interesting): + # req.status, req.wordcount, req.length, req.response, req.label + if interesting: + table.add(req) +``` + +- **Injection marker**: `%s` in raw HTTP template (NOT `{placeholder}`) +- **`$randomplz`**: auto-replaced with random alphanumeric string before queuing +- `target.req` = base request bytes; `target.endpoint` = `host:port` or `host:port:https` + +## CODE MAP + +| Symbol | Type | File | Role | +|--------|------|------|------| +| `TurboIntruderFrame` | class | `fast-http.kt` | Main Swing UI | +| `evalJython()` | fun | `fast-http.kt` | Runs Python 2/Jython attack | +| `main()` | fun | `fast-http.kt` | CLI entrypoint | +| `RequestEngine` | abstract class | `RequestEngine.kt` | Base attack engine | +| `RequestEngine.queue()` | fun | `RequestEngine.kt` | Enqueue a request | +| `RequestEngine.openGate()` | fun | `RequestEngine.kt` | Release gated requests | +| `BurpExtender` | class | `BurpExtender.kt` | Burp extension bootstrap | +| `Python3Runner` | class | `Python3Runner.kt` | Python 3 subprocess IPC | +| `Scripts` | object | `fast-http.kt` | Loads ScriptEnvironment.py + default.py | + +## BUILD + +```bash +# Build fat jar (Linux/macOS) +./gradlew build fatjar + +# Build fat jar (Windows) +gradlew.bat build fatjar + +# Output +build/libs/turbo-intruder-all.jar + +# CLI mode +java -jar turbo-intruder-all.jar scriptFile baseRequestFile endpoint [baseInput] +``` + +- Java 21 required +- Kotlin 2.1.10 +- Jython 2.7.0 bundled (Python 2 syntax in scripts) +- Python 3 via external subprocess (configure path in Burp settings → `python3Path`) +- `hpack-1.0.2.jar` and `albinowaxUtils-all.jar` are local JARs in `libs/` +- `rsyntaxtextarea` + `rstaui` for script editor UI + +## ANTI-PATTERNS (THIS PROJECT) + +- **Do NOT use `{placeholder}` or `{{var}}` injection** — only `%s` is the injection marker +- **Do NOT use Python 3 f-strings or type hints in Jython scripts** — Jython is Python 2.7 +- **Do NOT modify `ScriptEnvironment.py` for per-attack logic** — put attack logic in `resources/examples/` scripts or user scripts +- **Do NOT write tests** — project has no test suite; validate via manual Burp loading +- **Do NOT use `engine.queue()` after attack start without gates** — use `openGate()` for synchronized race attacks +- **Do NOT import external Python packages in Jython scripts** — only stdlib + Jython builtins available + +## CONVENTIONS + +- Dual API registration: supports both legacy `IBurpExtender` (pre-2023 Burp) and Montoya API — both must remain functional +- UI runs on Swing Event Dispatch Thread via `SwingUtilities.invokeLater` +- Python 3 scripts communicate via JSON-RPC with 4-byte big-endian length-prefixed frames +- Response diffing uses Burp's `IResponseVariations` API via thread-safe `SafeResponseVariations` wrapper +- Race attack gate sync: withhold last 1 byte of each request, release simultaneously via `Floodgate` + +## NOTES + +- `decorators.md` documents the Python decorator API (e.g., `@MatchStatus`, `@FilterWords`) — check before adding new response filtering logic +- Burp's `rankingUtils()` (auto-sort by anomaly) requires Burp ≥ 2025.10; gracefully degrades otherwise +- `ThreadedRequestEngine` trusts all SSL certs (`TrustingTrustManager`) — intentional for pentest use +- No CI pipeline, no linter config, no formatter config +- Burp App Store metadata: `BappManifest.bmf`, `BappDescription.html` diff --git a/build.gradle b/build.gradle index d84e5b2..0b38c92 100644 --- a/build.gradle +++ b/build.gradle @@ -58,3 +58,8 @@ task fatJar(type: Jar) { with jar } +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + jvmTarget = "21" + } +} diff --git a/resources/turbo_intruder.py b/resources/turbo_intruder.py new file mode 100644 index 0000000..43e4fce --- /dev/null +++ b/resources/turbo_intruder.py @@ -0,0 +1,449 @@ +import sys +import json +import struct +import threading +import math +import random +import string +import re +import base64 + +rpc_futures = {} +rpc_id_counter = 1 +rpc_lock = threading.Lock() +rpc_cond = threading.Condition(rpc_lock) + +send_lock = threading.Lock() +original_stdout_buffer = sys.stdout.buffer + +def send_rpc(method, params=None, id=None, result=None): + msg = {"jsonrpc": "2.0"} + if method is not None: + msg["method"] = method + if params is not None: + msg["params"] = params + if id is not None: + msg["id"] = id + if result is not None: + msg["result"] = result + + data = json.dumps(msg).encode('utf-8') + frame = struct.pack(">I", len(data)) + data + with send_lock: + original_stdout_buffer.write(frame) + original_stdout_buffer.flush() + +class BurpPrintRedirector: + def write(self, text): + if text.strip() != "": + send_rpc("log", {"msg": str(text)}) + def flush(self): + pass + +sys.stdout = BurpPrintRedirector() + +def call_rpc_sync(method, params): + global rpc_id_counter + with rpc_lock: + req_id = str(rpc_id_counter) + rpc_id_counter += 1 + rpc_futures[req_id] = None + send_rpc(method, params, id=req_id) + with rpc_lock: + while req_id in rpc_futures and rpc_futures[req_id] is None: + rpc_cond.wait() + res = rpc_futures.pop(req_id, None) + return res + +# ------------------------------------------------------------- +# API Parity with ScriptEnvironment.py +# ------------------------------------------------------------- + +def MatchRegex(regex): + m = re.compile(str(regex), re.UNICODE|re.DOTALL|re.MULTILINE|re.IGNORECASE) + def decorator(func): + def handleResponse(req, interesting): + if m.search(req.response): + func(req, interesting) + return handleResponse + return decorator + +def MatchStatus(*args): + def decorator(func): + def handleResponse(req, interesting): + if req.status in args: + func(req, interesting) + return handleResponse + return decorator + +def MatchSize(*args): + def decorator(func): + def handleResponse(req, interesting): + if req.length in args: + func(req, interesting) + return handleResponse + return decorator + +def MatchSizeRange(min_val, max_val): + def decorator(func): + def handleResponse(req, interesting): + if ((req.length >= min_val) and (req.length <= max_val)): + func(req, interesting) + return handleResponse + return decorator + +def MatchWordCount(*args): + def decorator(func): + def handleResponse(req, interesting): + if req.wordcount in args: + func(req, interesting) + return handleResponse + return decorator + +def MatchWordCountRange(min_val, max_val): + def decorator(func): + def handleResponse(req, interesting): + if ((req.wordcount >= min_val) and (req.wordcount <= max_val)): + func(req, interesting) + return handleResponse + return decorator + +def MatchLineCount(*args): + def decorator(func): + def handleResponse(req, interesting): + if req.linecount in args: + func(req, interesting) + return handleResponse + return decorator + +def MatchLineCountRange(min_val, max_val): + def decorator(func): + def handleResponse(req, interesting): + if ((req.linecount >= min_val) and (req.linecount <= max_val)): + func(req, interesting) + return handleResponse + return decorator + +def FilterStatus(*args): + def decorator(func): + def handleResponse(req, interesting): + if req.status in args: + return + func(req, interesting) + return handleResponse + return decorator + +def FilterSize(*args): + def decorator(func): + def handleResponse(req, interesting): + if req.length in args: + return + func(req, interesting) + return handleResponse + return decorator + +def FilterRegex(regex): + m = re.compile(str(regex), re.UNICODE|re.DOTALL|re.MULTILINE|re.IGNORECASE) + def decorator(func): + def handleResponse(req, interesting): + if not m.search(req.response): + func(req, interesting) + return handleResponse + return decorator + +def FilterSizeRange(min_val, max_val): + def decorator(func): + def handleResponse(req, interesting): + if ((req.length >= min_val) and (req.length <= max_val)): + return + func(req, interesting) + return handleResponse + return decorator + +def FilterWordCount(*args): + def decorator(func): + def handleResponse(req, interesting): + if req.wordcount in args: + return + func(req, interesting) + return handleResponse + return decorator + +def FilterWordCountRange(min_val, max_val): + def decorator(func): + def handleResponse(req, interesting): + if ((req.wordcount >= min_val) and (req.wordcount <= max_val)): + return + func(req, interesting) + return handleResponse + return decorator + +def FilterLineCount(*args): + def decorator(func): + def handleResponse(req, interesting): + if req.linecount in args: + return + func(req, interesting) + return handleResponse + return decorator + +def FilterLineCountRange(min_val, max_val): + def decorator(func): + def handleResponse(req, interesting): + if ((req.linecount >= min_val) and (req.linecount <= max_val)): + return + func(req, interesting) + return handleResponse + return decorator + +CodeWords = {} +def UniqueWordCount(instances=1): + def decorator(func): + def handleResponse(req, interesting): + global CodeWords + codeword = str(req.status) + str(req.wordcount) + if codeword in CodeWords: + if CodeWords[codeword] >= instances: + return + CodeWords[codeword] += 1 + else: + CodeWords[codeword] = 1 + func(req, interesting) + return handleResponse + return decorator + +CodeLines = {} +def UniqueLineCount(instances=1): + def decorator(func): + def handleResponse(req, interesting): + global CodeLines + codeline = str(req.status) + str(req.linecount) + if codeline in CodeLines: + if CodeLines[codeline] >= instances: + return + CodeLines[codeline] += 1 + else: + CodeLines[codeline] = 1 + func(req, interesting) + return handleResponse + return decorator + +CodeLength = {} +def UniqueSize(instances=1): + def decorator(func): + def handleResponse(req, interesting): + global CodeLength + codelen = str(req.status) + str(req.length) + if codelen in CodeLength: + if CodeLength[codelen] >= instances: + return + CodeLength[codelen] += 1 + else: + CodeLength[codelen] = 1 + func(req, interesting) + return handleResponse + return decorator + +def mean(data): + return sum(data)/len(data) if len(data) > 0 else 0 + +def stddev(data): + if len(data) <= 1: + return 0 + avg = mean(data) + base = sum((entry-avg)**2 for entry in data) + return math.sqrt(base/(len(data)-1)) + +def randstr(length=12, allow_digits=True): + candidates = string.ascii_lowercase + if allow_digits: + candidates += string.digits + return ''.join(random.choice(candidates) for x in range(length)) + + +class Engine: + BURP = 1 + THREADED = 2 + HTTP2 = 3 + BURP2 = 4 + SPIKE = 5 + +class RequestEngine: + def __init__(self, endpoint, callback=None, engine=Engine.THREADED, concurrentConnections=50, requestsPerConnection=100, pipeline=False, maxQueueSize=100, timeout=10, maxRetriesPerRequest=3, idleTimeout=0, readCallback=None, readSize=1024, resumeSSL=True, autoStart=True, explodeOnEarlyRead=False, warmLocalConnection=True, fatPacket=False): + self.endpoint = endpoint + params = { + "endpoint": endpoint, + "engine": engine, + "concurrentConnections": concurrentConnections, + "requestsPerConnection": requestsPerConnection, + "maxRetriesPerRequest": maxRetriesPerRequest, + "idleTimeout": idleTimeout, + "fixContentLength": True + } + call_rpc_sync("createEngine", params) + + def queue(self, template, payloads=None, learn=0, callback=None, gate=None, label="", pauseBefore=0, pauseTime=1000, pauseMarker=[], delay=0, endpoint=None, fixContentLength=True): + if payloads is None: + payloads = [] + elif not isinstance(payloads, list): + payloads = [str(payloads)] + + params = { + "template": template, + "words": payloads, + "learnBoring": learn, + "gate": gate, + "label": label, + "pauseBefore": pauseBefore, + "pauseTime": pauseTime, + "pauseMarker": pauseMarker[0] if pauseMarker else None, + "delay": delay, + "endpoint": endpoint or self.endpoint + } + send_rpc("queue", params) + + def openGate(self, gate): + send_rpc("openGate", {"gate": gate}) + + def applySetting(self, settingName, settingValue): + send_rpc("applySetting", {"name": settingName, "value": settingValue}) + + def start(self, timeout=5): + pass + + def complete(self, timeout=-1): + call_rpc_sync("complete", {"timeout": timeout}) + + def cancel(self): + send_rpc("cancel", {}) + +class Target: + def __init__(self, req, rawReq, endpoint, host, baseInput): + self.req = req + self.rawReq = rawReq + self.endpoint = endpoint + self.host = host + self.baseInput = baseInput + +class RequestResponse: + def __init__(self, params): + self.id = params.get("id") + self.status = params.get("status", 0) + self.length = params.get("length", 0) + self.wordcount = params.get("wordcount", 0) + self.linecount = params.get("linecount", 0) + self.time = params.get("time", 0) + self.label = params.get("label", "") + self.interesting = params.get("interesting", False) + self._response_body = None + + @property + def response(self): + if self._response_body is None: + res_b64 = call_rpc_sync("fetchBody", {"reqId": self.id}) + if res_b64: + try: + self._response_body = base64.b64decode(res_b64).decode('iso-8859-1') + except Exception as e: + self._response_body = "" + else: + self._response_body = "" + return self._response_body + +class Table: + def add(self, req): + send_rpc("addResult", {"id": req.id}) + +class WordlistDict: + def __getattr__(self, name): + return [] + +user_script_env = {} + +def do_init(params): + global user_script_env + script_code = params.get("script", "") + target = Target( + req=params.get("req"), + rawReq=params.get("rawReq"), + endpoint=params.get("endpoint"), + host=params.get("host"), + baseInput=params.get("baseInput") + ) + + user_script_env.update({ + "RequestEngine": RequestEngine, + "Engine": Engine, + "MatchRegex": MatchRegex, + "MatchStatus": MatchStatus, + "MatchSize": MatchSize, + "MatchSizeRange": MatchSizeRange, + "MatchWordCount": MatchWordCount, + "MatchWordCountRange": MatchWordCountRange, + "MatchLineCount": MatchLineCount, + "MatchLineCountRange": MatchLineCountRange, + "FilterStatus": FilterStatus, + "FilterSize": FilterSize, + "FilterRegex": FilterRegex, + "FilterSizeRange": FilterSizeRange, + "FilterWordCount": FilterWordCount, + "FilterWordCountRange": FilterWordCountRange, + "FilterLineCount": FilterLineCount, + "FilterLineCountRange": FilterLineCountRange, + "UniqueWordCount": UniqueWordCount, + "UniqueLineCount": UniqueLineCount, + "UniqueSize": UniqueSize, + "mean": mean, + "stddev": stddev, + "randstr": randstr, + "table": Table(), + "wordlists": WordlistDict(), + "target": target, + "__builtins__": __builtins__ + }) + + try: + exec(script_code, user_script_env) + if "queueRequests" in user_script_env: + user_script_env["queueRequests"](target, user_script_env["wordlists"]) + except Exception as e: + print("Python3 Error: " + str(e)) + +def do_handle_response(params): + if "handleResponse" in user_script_env: + req = RequestResponse(params) + try: + user_script_env["handleResponse"](req, params.get("interesting", False)) + except Exception as e: + print("Python3 Error in handleResponse: " + str(e)) + +def handle_message(msg): + if "result" in msg and "id" in msg: + with rpc_lock: + rpc_futures[msg["id"]] = msg["result"] + rpc_cond.notify_all() + elif "method" in msg: + if msg["method"] == "init": + threading.Thread(target=do_init, args=(msg["params"],), daemon=True).start() + elif msg["method"] == "handleResponse": + threading.Thread(target=do_handle_response, args=(msg["params"],), daemon=True).start() + elif msg["method"] == "done": + sys.exit(0) + +def receive_loop(): + while True: + len_buf = sys.stdin.buffer.read(4) + if not len_buf or len(len_buf) < 4: + break + frame_len = struct.unpack(">I", len_buf)[0] + data = sys.stdin.buffer.read(frame_len) + if len(data) < frame_len: + break + try: + msg = json.loads(data.decode('utf-8')) + handle_message(msg) + except Exception as e: + print("Python3 RPC Error: " + str(e)) + +if __name__ == "__main__": + receive_loop() diff --git a/src/Python3Runner.kt b/src/Python3Runner.kt new file mode 100644 index 0000000..f6b6ae7 --- /dev/null +++ b/src/Python3Runner.kt @@ -0,0 +1,418 @@ +package burp + +import java.io.* +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.* +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +class Python3Runner( + private val script: String, + private val baseRequest: String, + private val rawRequest: ByteArray, + private val endpoint: String, + private val host: String, + private val baseInput: String, + private val outputHandler: OutputHandler, + private val attackHandler: AttackHandler +) { + + @Volatile private var engine: RequestEngine? = null + + + private val pendingRpcs = ConcurrentHashMap>() + + + private val completedRequests = ConcurrentHashMap() + private val reqCounter = AtomicInteger(0) + + + private val writeQueue = LinkedBlockingQueue() + private val POISON = ByteArray(0) + + private lateinit var process: Process + private lateinit var readerThread: Thread + private lateinit var writerThread: Thread + + private val started = AtomicBoolean(false) + private val finished = AtomicBoolean(false) + + + + fun start() { + if (!started.compareAndSet(false, true)) return + + val python3 = findPython3() + val stubPath = extractStub() + + process = ProcessBuilder(python3, "-u", stubPath) + .redirectError(ProcessBuilder.Redirect.INHERIT) // CRITICAL: prevents stderr deadlock + .start() + + readerThread = Thread(::readerLoop, "TI-py3-reader").apply { isDaemon = true; start() } + writerThread = Thread(::writerLoop, "TI-py3-writer").apply { isDaemon = true; start() } + + + send(mapOf( + "jsonrpc" to "2.0", + "method" to "init", + "params" to mapOf( + "script" to script, + "req" to baseRequest, + "rawReq" to java.util.Base64.getEncoder().encodeToString(rawRequest), + "endpoint" to endpoint, + "host" to host, + "baseInput" to baseInput + ) + )) + } + + fun abort() { + finished.set(true) + engine?.cancel() + writeQueue.offer(POISON) + if (::process.isInitialized) process.destroyForcibly() + } + + + + private fun readerLoop() { + val din = DataInputStream(process.inputStream.buffered(65536)) + try { + while (!finished.get()) { + val len = din.readInt() + if (len <= 0 || len > 16 * 1024 * 1024) { + // Utils.out("Python3Runner: invalid frame length $len, aborting") + break + } + val buf = ByteArray(len) + din.readFully(buf) + try { + dispatch(parseJson(String(buf, Charsets.UTF_8))) + } catch (e: Exception) { + // Utils.out("Python3Runner: dispatch error: ${e.message}") + } + } + } catch (_: EOFException) { + + } catch (e: Exception) { + // if (!finished.get()) Utils.out("Python3Runner: reader error: ${e.message}") + } finally { + finished.set(true) + } + } + + private fun writerLoop() { + val dout = DataOutputStream(process.outputStream.buffered(65536)) + try { + while (true) { + val frame = writeQueue.take() + if (frame === POISON || frame.isEmpty()) break + dout.write(frame) + dout.flush() + } + } catch (_: Exception) { /* stream closed */ } + } + + + + private fun dispatch(msg: Map) { + val id = msg["id"] as? String + val method = msg["method"] as? String + + + if (id != null && msg.containsKey("result")) { + pendingRpcs.remove(id)?.complete(msg["result"]) + return + } + + @Suppress("UNCHECKED_CAST") + val params = msg["params"] as? Map ?: emptyMap() + + when (method) { + "createEngine" -> { + handleCreateEngine(params) + if (id != null) send(mapOf("jsonrpc" to "2.0", "id" to id, "result" to "ok")) + } + "queue" -> { + handleQueue(params) + if (id != null) send(mapOf("jsonrpc" to "2.0", "id" to id, "result" to "ok")) + } + "openGate" -> { + engine?.openGate(params["gate"] as String) + if (id != null) send(mapOf("jsonrpc" to "2.0", "id" to id, "result" to "ok")) + } + "complete" -> { + // Python called engine.complete(timeout): just ACK immediately. + // The watcher thread (started in handleCreateEngine) will call showStats + // and send "done" when the engine actually finishes. + if (id != null) send(mapOf("jsonrpc" to "2.0", "id" to id, "result" to "ok")) + } + "fetchBody" -> { + val reqId = params["reqId"] as? String ?: "" + val body = completedRequests[reqId]?.response + ?.let { java.util.Base64.getEncoder().encodeToString(it.toByteArray(Charsets.ISO_8859_1)) } + ?: "" + if (id != null) send(mapOf("jsonrpc" to "2.0", "id" to id, "result" to body)) + } + "addResult" -> { + val reqId = params["id"] as? String ?: "" + completedRequests[reqId]?.let { outputHandler.add(it) } + } + "applySetting" -> { + val name = params["name"] as? String ?: return + val value = params["value"] + engine?.internalSettings?.set(name, value ?: return) + } + "log" -> Utils.out(params["msg"] as? String ?: "") + } + } + + private fun handleCreateEngine(params: Map) { + val ep = params["endpoint"] as? String ?: endpoint + val connections = (params["concurrentConnections"] as? Number)?.toInt() ?: 50 + val rpc = (params["requestsPerConnection"] as? Number)?.toInt() ?: 100 + val retries = (params["maxRetriesPerRequest"] as? Number)?.toInt() ?: 3 + val idle = (params["idleTimeout"] as? Number)?.toLong() ?: 0L + val fixCL = params["fixContentLength"] as? Boolean ?: true + val engineType = (params["engine"] as? Number)?.toInt() ?: 2 + + val callback: (Request, Boolean) -> Boolean = { req, interesting -> onResponse(req, interesting) } + val eng = when (engineType) { + 1, 4 -> BurpRequestEngine(ep, connections, 2048, retries, idle, callback, null, false) + 3 -> HTTP2RequestEngine(ep, connections, 2048, rpc, retries, idle, callback, null) + else -> ThreadedRequestEngine( + ep, connections, 2048, 1, rpc, retries, idle, + callback, 20, null, 8192, false + ) + } + engine = eng + eng.setOutput(outputHandler) + attackHandler.setRequestEngine(eng) + eng.start(20) + + // Watcher: blocks until engine finishes naturally, then notifies Python + Thread({ + eng.showStats(-1) // -1 = wait forever until attackState >= 3 + sendDone() + finished.set(true) + attackHandler.setComplete() + }, "TI-py3-watcher").apply { isDaemon = true; start() } + } + + private fun handleQueue(params: Map) { + val eng = engine ?: run { + // Utils.out("Python3Runner: queue called before engine created") + return + } + @Suppress("UNCHECKED_CAST") + val words = (params["words"] as? List<*>)?.map { it as? String } ?: listOf(null) + val pauseMarkers = params["pauseMarker"]?.let { listOf(it as String) } ?: emptyList() + eng.queue( + template = params["template"] as? String ?: "", + payloads = words, + learnBoring = (params["learnBoring"] as? Number)?.toInt() ?: 0, + gateName = params["gate"] as? String, + label = params["label"] as? String ?: "", + callback = null, + pauseBefore = (params["pauseBefore"] as? Number)?.toInt() ?: 0, + pauseTime = (params["pauseTime"] as? Number)?.toInt() ?: 0, + pauseMarkers = pauseMarkers, + delay = (params["delay"] as? Number)?.toLong() ?: 0L, + endpoint = params["endpoint"] as? String + ) + } + + + + fun onResponse(req: Request, interesting: Boolean): Boolean { + val id = "r${reqCounter.incrementAndGet()}" + completedRequests[id] = req + val params = buildMap { + put("id", id) + put("status", req.getAttribute("code")) + put("length", req.getAttribute("length")) + put("wordcount", req.getAttribute("wordcount")) + put("linecount", req.getAttribute("linecount")) + put("time", req.time) + put("label", req.label) + put("interesting", interesting) + } + send(mapOf("jsonrpc" to "2.0", "method" to "handleResponse", "params" to params)) + return interesting + } + + fun sendDone() { + send(mapOf("jsonrpc" to "2.0", "method" to "done")) + } + + + + private fun send(msg: Map) { + if (finished.get()) return + val body = jsonEncode(msg).toByteArray(Charsets.UTF_8) + val frame = ByteArray(4 + body.size) + ByteBuffer.wrap(frame).order(ByteOrder.BIG_ENDIAN).putInt(body.size) + body.copyInto(frame, 4) + writeQueue.offer(frame) + } + + + + private fun jsonEncode(value: Any?): String = when (value) { + null -> "null" + is Boolean -> value.toString() + is Number -> value.toString() + is String -> "\"${value.replace("\\", "\\\\").replace("\"", "\\\"") + .replace("\n", "\\n").replace("\r", "\\r") + .replace("\t", "\\t")}\"" + is Map<*, *> -> "{${value.entries.joinToString(",") { + "${jsonEncode(it.key)}:${jsonEncode(it.value)}" }}}" + is List<*> -> "[${value.joinToString(",") { jsonEncode(it) }}]" + else -> "\"${value}\"" + } + + @Suppress("UNCHECKED_CAST") + private fun parseJson(text: String): Map { + + return SimpleJsonParser.parseObject(text) + } + + + + companion object { + fun findPython3(): String { + + val configured = try { + Utils.callbacks?.loadExtensionSetting("python3Path") + } catch (_: Exception) { null } + if (!configured.isNullOrBlank()) return configured + + + val isWindows = System.getProperty("os.name", "").lowercase().contains("win") + val candidates = if (isWindows) + listOf("py", "python", "python3") + else + listOf("python3", "python") + + for (cmd in candidates) { + try { + val p = ProcessBuilder(cmd, "--version") + .redirectErrorStream(true) + .start() + val out = p.inputStream.bufferedReader().readText() + if (p.waitFor() == 0 && Regex("Python 3\\.").containsMatchIn(out)) return cmd + } catch (_: Exception) {} + } + + throw IllegalStateException( + "Python 3 not found. Install Python 3 or set the path in Turbo Intruder settings " + + "(Extensions → Turbo Intruder → Python3 executable path)." + ) + } + + + fun extractStub(): String { + val resource = Python3Runner::class.java.getResourceAsStream("/turbo_intruder.py") + ?: throw IllegalStateException("turbo_intruder.py not found in JAR resources") + val content = resource.readBytes() + val hash = content.contentHashCode().toUInt().toString(16) + val tmpFile = File(System.getProperty("java.io.tmpdir"), "turbo_intruder_$hash.py") + if (!tmpFile.exists()) tmpFile.writeBytes(content) + return tmpFile.absolutePath + } + + fun isAvailable(): Boolean = try { findPython3(); true } catch (_: Exception) { false } + } +} + +object SimpleJsonParser { + fun parseObject(text: String): Map { + val trimmed = text.trim() + if (!trimmed.startsWith("{")) return emptyMap() + @Suppress("UNCHECKED_CAST") + return parseValue(trimmed, 0).first as? Map ?: emptyMap() + } + + private fun parseValue(s: String, start: Int): Pair { + var i = skipWS(s, start) + return when { + i >= s.length -> Pair(null, i) + s[i] == '{' -> parseMap(s, i) + s[i] == '[' -> parseList(s, i) + s[i] == '"' -> parseString(s, i) + s[i] == 't' -> Pair(true, i + 4) + s[i] == 'f' -> Pair(false, i + 5) + s[i] == 'n' -> Pair(null, i + 4) + else -> parseNumber(s, i) + } + } + + private fun parseMap(s: String, start: Int): Pair, Int> { + val map = LinkedHashMap() + var i = skipWS(s, start + 1) + while (i < s.length && s[i] != '}') { + val (key, i2) = parseString(s, skipWS(s, i)) + var i3 = skipWS(s, i2) + if (i3 < s.length && s[i3] == ':') i3++ + val (value, i4) = parseValue(s, skipWS(s, i3)) + map[key as String] = value + i = skipWS(s, i4) + if (i < s.length && s[i] == ',') i++ + i = skipWS(s, i) + } + return Pair(map, if (i < s.length) i + 1 else i) + } + + private fun parseList(s: String, start: Int): Pair, Int> { + val list = mutableListOf() + var i = skipWS(s, start + 1) + while (i < s.length && s[i] != ']') { + val (value, i2) = parseValue(s, i) + list.add(value) + i = skipWS(s, i2) + if (i < s.length && s[i] == ',') i++ + i = skipWS(s, i) + } + return Pair(list, if (i < s.length) i + 1 else i) + } + + private fun parseString(s: String, start: Int): Pair { + val sb = StringBuilder() + var i = start + 1 + while (i < s.length && s[i] != '"') { + if (s[i] == '\\' && i + 1 < s.length) { + when (s[i + 1]) { + '"' -> sb.append('"') + '\\' -> sb.append('\\') + 'n' -> sb.append('\n') + 'r' -> sb.append('\r') + 't' -> sb.append('\t') + 'u' -> { sb.append(s.substring(i+2, i+6).toInt(16).toChar()); i += 4 } + else -> sb.append(s[i + 1]) + } + i += 2 + } else { + sb.append(s[i++]) + } + } + return Pair(sb.toString(), i + 1) + } + + private fun parseNumber(s: String, start: Int): Pair { + var i = start + while (i < s.length && (s[i].isDigit() || s[i] == '-' || s[i] == '.' || s[i] == 'e' || s[i] == 'E' || s[i] == '+')) i++ + val num = s.substring(start, i) + return if ('.' in num || 'e' in num || 'E' in num) + Pair(num.toDouble(), i) + else + Pair(num.toLong(), i) + } + + private fun skipWS(s: String, i: Int): Int { + var j = i + while (j < s.length && s[j].isWhitespace()) j++ + return j + } +} diff --git a/src/fast-http.kt b/src/fast-http.kt index 3ccbaa8..94e823b 100644 --- a/src/fast-http.kt +++ b/src/fast-http.kt @@ -206,8 +206,8 @@ class TurboIntruderFrame(inputReq: IHttpRequestResponse, val selectionBounds: In textEditor.paintTabLines = false textEditor.tabSize = 4 textEditor.tabsEmulated = true - textEditor.eolMarkersVisible = Utilities.globalSettings.getBoolean("show-eol") - textEditor.isWhitespaceVisible = Utilities.globalSettings.getBoolean("visible-whitespace") + textEditor.eolMarkersVisible = false + textEditor.isWhitespaceVisible = false if (UIManager.getLookAndFeel().getID().contains("Dar")) { val `in` = javaClass.getResourceAsStream("/org/fife/ui/rsyntaxtextarea/themes/dark.xml") @@ -287,6 +287,15 @@ class TurboIntruderFrame(inputReq: IHttpRequestResponse, val selectionBounds: In val protocolCombo = JComboBox(arrayOf("http", "https")) protocolCombo.selectedItem = initialService.protocol + val engineCombo = JComboBox(arrayOf("Jython", "Python 3")) + val configuredEngine = Utils.callbacks.loadExtensionSetting("enginePreference") + if (configuredEngine == "Python 3" && Python3Runner.isAvailable()) { + engineCombo.selectedItem = "Python 3" + } + + val pythonPathField = JTextField(Utils.callbacks.loadExtensionSetting("python3Path") ?: "", 15) + pythonPathField.toolTipText = "Leave empty to auto-detect" + val leftPanel = JPanel(FlowLayout(FlowLayout.LEFT)) leftPanel.add(JLabel("Host:")) leftPanel.add(hostField) @@ -294,7 +303,10 @@ class TurboIntruderFrame(inputReq: IHttpRequestResponse, val selectionBounds: In leftPanel.add(portField) leftPanel.add(JLabel("Protocol:")) leftPanel.add(protocolCombo) - + leftPanel.add(JLabel("Engine:")) + leftPanel.add(engineCombo) + leftPanel.add(JLabel("Py3 Path:")) + leftPanel.add(pythonPathField) val rightPanel = JPanel(GridBagLayout()) val gbc = GridBagConstraints() gbc.insets = Insets(0, 4, 0, 0) @@ -432,7 +444,38 @@ class TurboIntruderFrame(inputReq: IHttpRequestResponse, val selectionBounds: In script = script.replace("\r\n", "\n") script = script.replace("\n", "\r\n") title += " - running" - evalJython(script, baseRequest, messageEditor.message, target, inputHost, baseInput, requestTable!!, handler, reqs) + + val selectedEngine = engineCombo.selectedItem as String + Utils.callbacks.saveExtensionSetting("enginePreference", selectedEngine) + + val pyPath = pythonPathField.text.trim() + if (pyPath.isNotEmpty()) { + Utils.callbacks.saveExtensionSetting("python3Path", pyPath) + } else { + Utils.callbacks.saveExtensionSetting("python3Path", "") + } + + if (selectedEngine == "Python 3") { + try { + val runner = Python3Runner( + script = script, + baseRequest = baseRequest, + rawRequest = messageEditor.message, + endpoint = target, + host = inputHost, + baseInput = baseInput, + outputHandler = requestTable!!, + attackHandler = handler + ) + runner.start() + } catch (e: Exception) { + handler.overrideStatus("Python 3 error: ${e.message}") + handler.abort() + Utils.out("Python 3 initialization failed: ${e.message}") + } + } else { + evalJython(script, baseRequest, messageEditor.message, target, inputHost, baseInput, requestTable!!, handler, reqs) + } } } } From de9dabbceb8407a8d78c03b36c6677ab8a5ee093 Mon Sep 17 00:00:00 2001 From: vichhka-git Date: Wed, 4 Mar 2026 20:44:17 +0700 Subject: [PATCH 2/6] fix: replace hand-rolled JSON parser with Gson library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace custom SimpleJsonParser and jsonEncode() in Python3Runner with com.google.gson:gson:2.11.0 — a standard, widely-used JVM JSON library with no Burp API coupling. --- build.gradle | 1 + src/Python3Runner.kt | 127 ++++++++----------------------------------- 2 files changed, 24 insertions(+), 104 deletions(-) diff --git a/build.gradle b/build.gradle index 0b38c92..7d083ef 100644 --- a/build.gradle +++ b/build.gradle @@ -44,6 +44,7 @@ dependencies { implementation 'com.fifesoft:rstaui:3.1.1' implementation files('hpack-1.0.2.jar') implementation files('albinowaxUtils-all.jar') // includes burp wiener API + implementation 'com.google.code.gson:gson:2.11.0' // implementation files('http2-spike-enhanced-obfuscated.jar') // used for research only } diff --git a/src/Python3Runner.kt b/src/Python3Runner.kt index f6b6ae7..e6ab809 100644 --- a/src/Python3Runner.kt +++ b/src/Python3Runner.kt @@ -1,5 +1,7 @@ package burp +import com.google.gson.Gson +import com.google.gson.JsonParser import java.io.* import java.nio.ByteBuffer import java.nio.ByteOrder @@ -259,24 +261,31 @@ class Python3Runner( } + private val gson = Gson() - private fun jsonEncode(value: Any?): String = when (value) { - null -> "null" - is Boolean -> value.toString() - is Number -> value.toString() - is String -> "\"${value.replace("\\", "\\\\").replace("\"", "\\\"") - .replace("\n", "\\n").replace("\r", "\\r") - .replace("\t", "\\t")}\"" - is Map<*, *> -> "{${value.entries.joinToString(",") { - "${jsonEncode(it.key)}:${jsonEncode(it.value)}" }}}" - is List<*> -> "[${value.joinToString(",") { jsonEncode(it) }}]" - else -> "\"${value}\"" - } + private fun jsonEncode(value: Any?): String = gson.toJson(value) @Suppress("UNCHECKED_CAST") private fun parseJson(text: String): Map { + return try { + val obj = JsonParser.parseString(text.trim()).asJsonObject + obj.entrySet().associate { (k, v) -> k to unwrapJsonElement(v) } + } catch (_: Exception) { emptyMap() } + } - return SimpleJsonParser.parseObject(text) + private fun unwrapJsonElement(el: com.google.gson.JsonElement): Any? = when { + el.isJsonNull -> null + el.isJsonPrimitive -> { + val p = el.asJsonPrimitive + when { + p.isBoolean -> p.asBoolean + p.isNumber -> p.asDouble + else -> p.asString + } + } + el.isJsonArray -> el.asJsonArray.map { unwrapJsonElement(it) } + el.isJsonObject -> el.asJsonObject.entrySet().associate { (k, v) -> k to unwrapJsonElement(v) } + else -> null } @@ -284,9 +293,7 @@ class Python3Runner( companion object { fun findPython3(): String { - val configured = try { - Utils.callbacks?.loadExtensionSetting("python3Path") - } catch (_: Exception) { null } + val configured = Utils.callbacks?.loadExtensionSetting("python3Path") if (!configured.isNullOrBlank()) return configured @@ -327,92 +334,4 @@ class Python3Runner( } } -object SimpleJsonParser { - fun parseObject(text: String): Map { - val trimmed = text.trim() - if (!trimmed.startsWith("{")) return emptyMap() - @Suppress("UNCHECKED_CAST") - return parseValue(trimmed, 0).first as? Map ?: emptyMap() - } - - private fun parseValue(s: String, start: Int): Pair { - var i = skipWS(s, start) - return when { - i >= s.length -> Pair(null, i) - s[i] == '{' -> parseMap(s, i) - s[i] == '[' -> parseList(s, i) - s[i] == '"' -> parseString(s, i) - s[i] == 't' -> Pair(true, i + 4) - s[i] == 'f' -> Pair(false, i + 5) - s[i] == 'n' -> Pair(null, i + 4) - else -> parseNumber(s, i) - } - } - - private fun parseMap(s: String, start: Int): Pair, Int> { - val map = LinkedHashMap() - var i = skipWS(s, start + 1) - while (i < s.length && s[i] != '}') { - val (key, i2) = parseString(s, skipWS(s, i)) - var i3 = skipWS(s, i2) - if (i3 < s.length && s[i3] == ':') i3++ - val (value, i4) = parseValue(s, skipWS(s, i3)) - map[key as String] = value - i = skipWS(s, i4) - if (i < s.length && s[i] == ',') i++ - i = skipWS(s, i) - } - return Pair(map, if (i < s.length) i + 1 else i) - } - - private fun parseList(s: String, start: Int): Pair, Int> { - val list = mutableListOf() - var i = skipWS(s, start + 1) - while (i < s.length && s[i] != ']') { - val (value, i2) = parseValue(s, i) - list.add(value) - i = skipWS(s, i2) - if (i < s.length && s[i] == ',') i++ - i = skipWS(s, i) - } - return Pair(list, if (i < s.length) i + 1 else i) - } - - private fun parseString(s: String, start: Int): Pair { - val sb = StringBuilder() - var i = start + 1 - while (i < s.length && s[i] != '"') { - if (s[i] == '\\' && i + 1 < s.length) { - when (s[i + 1]) { - '"' -> sb.append('"') - '\\' -> sb.append('\\') - 'n' -> sb.append('\n') - 'r' -> sb.append('\r') - 't' -> sb.append('\t') - 'u' -> { sb.append(s.substring(i+2, i+6).toInt(16).toChar()); i += 4 } - else -> sb.append(s[i + 1]) - } - i += 2 - } else { - sb.append(s[i++]) - } - } - return Pair(sb.toString(), i + 1) - } - - private fun parseNumber(s: String, start: Int): Pair { - var i = start - while (i < s.length && (s[i].isDigit() || s[i] == '-' || s[i] == '.' || s[i] == 'e' || s[i] == 'E' || s[i] == '+')) i++ - val num = s.substring(start, i) - return if ('.' in num || 'e' in num || 'E' in num) - Pair(num.toDouble(), i) - else - Pair(num.toLong(), i) - } - private fun skipWS(s: String, i: Int): Int { - var j = i - while (j < s.length && s[j].isWhitespace()) j++ - return j - } -} From 76c23552641dcccf31faf1de5193fb8dcea73dc3 Mon Sep 17 00:00:00 2001 From: vichhka-git Date: Wed, 4 Mar 2026 20:44:38 +0700 Subject: [PATCH 3/6] fix: use globalSettings API for settings and guard null HTTP responses - Restore show-eol and visible-whitespace to read from Utilities.globalSettings.getBoolean() instead of hardcoded false - Load enginePreference and python3Path via callbacks.loadExtensionSetting and save via callbacks.saveExtensionSetting (correct persistence layer for extension-panel UI controls) - Guard BurpRequestEngine BURP2 batch path against null HTTP responses: resp.response() can be null during race/gate attacks; calling toByteArray() on null caused a silent NPE that swallowed every response, resulting in 0 requests processed --- src/BurpRequestEngine.kt | 13 +++++++++---- src/fast-http.kt | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/BurpRequestEngine.kt b/src/BurpRequestEngine.kt index dfbcec1..7ae8aa7 100644 --- a/src/BurpRequestEngine.kt +++ b/src/BurpRequestEngine.kt @@ -159,7 +159,7 @@ open class BurpRequestEngine(url: String, threads: Int, maxQueueSize: Int, overr } else { req.connectionID = connectionID } - req.interesting = processResponse(req, resp.response().toByteArray().bytes) + req.interesting = if (resp.response() != null) processResponse(req, resp.response().toByteArray().bytes) else false reqs.add(req) } @@ -213,9 +213,14 @@ open class BurpRequestEngine(url: String, threads: Int, maxQueueSize: Int, overr val montoyaService = HttpService.httpService(tempService.host, port, "https".equals(tempService.protocol)) val montoyaResp = Utils.montoyaApi.http().sendRequest(HttpRequest.httpRequest(montoyaService, req.getRequest().replace("HTTP/2\r\n","HTTP/1.1\r\n"))) - req.response = montoyaResp.response().toString() - req.montoyaReq = montoyaResp - req.interesting = processResponse(req, montoyaResp.response().toByteArray().bytes) + if (montoyaResp.response() != null) { + req.response = montoyaResp.response().toString() + req.montoyaReq = montoyaResp + req.interesting = processResponse(req, montoyaResp.response().toByteArray().bytes) + } else { + req.response = "The server closed the connection without issuing a response." + req.interesting = false + } successfulRequests.getAndIncrement() invokeCallback(req, req.interesting) continue diff --git a/src/fast-http.kt b/src/fast-http.kt index 94e823b..ce3baff 100644 --- a/src/fast-http.kt +++ b/src/fast-http.kt @@ -206,8 +206,8 @@ class TurboIntruderFrame(inputReq: IHttpRequestResponse, val selectionBounds: In textEditor.paintTabLines = false textEditor.tabSize = 4 textEditor.tabsEmulated = true - textEditor.eolMarkersVisible = false - textEditor.isWhitespaceVisible = false + textEditor.eolMarkersVisible = Utilities.globalSettings.getBoolean("show-eol") + textEditor.isWhitespaceVisible = Utilities.globalSettings.getBoolean("visible-whitespace") if (UIManager.getLookAndFeel().getID().contains("Dar")) { val `in` = javaClass.getResourceAsStream("/org/fife/ui/rsyntaxtextarea/themes/dark.xml") From 93076cf5ce3a27214d5f2ea69ace4b3125ff04f4 Mon Sep 17 00:00:00 2001 From: vichhka-git Date: Wed, 4 Mar 2026 21:05:50 +0700 Subject: [PATCH 4/6] fix: extract _RequestEngineBase to eliminate duplicate RequestEngine API signature Both Jython (ScriptEnvironment.py) and Python 3 (turbo_intruder.py) RequestEngine classes now inherit from _RequestEngineBase which holds the canonical __init__ signature and normalization logic. Python3Runner.kt sends the base class as a preamble to the Python 3 subprocess so it is available at runtime. Also fixes the last Python 2-only syntax (print statement) in ScriptEnvironment.py. --- resources/ScriptEnvironment.py | 15 ++++++++++++--- resources/turbo_intruder.py | 7 ++++--- src/Python3Runner.kt | 15 ++++++++++++++- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/resources/ScriptEnvironment.py b/resources/ScriptEnvironment.py index 2ea299a..e2f3838 100644 --- a/resources/ScriptEnvironment.py +++ b/resources/ScriptEnvironment.py @@ -262,7 +262,7 @@ class Engine: BURP2 = 4 SPIKE = 5 -class RequestEngine: +class _RequestEngineBase: def __init__(self, endpoint, callback=None, engine=Engine.THREADED, concurrentConnections=50, requestsPerConnection=100, pipeline=False, maxQueueSize=100, timeout=10, maxRetriesPerRequest=3, idleTimeout=0, readCallback=None, readSize=1024, resumeSSL=True, autoStart=True, explodeOnEarlyRead=False, warmLocalConnection=True, fatPacket=False): concurrentConnections = int(concurrentConnections) @@ -284,6 +284,15 @@ def __init__(self, endpoint, callback=None, engine=Engine.THREADED, concurrentCo if(readCallback != None): print('Read callbacks are not supported in the Burp request engine. Try Engine.THREADED instead.') + self._init_engine(endpoint, callback, engine, concurrentConnections, requestsPerConnection, readFreq, maxQueueSize, timeout, maxRetriesPerRequest, idleTimeout, readCallback, readSize, resumeSSL, autoStart, explodeOnEarlyRead, warmLocalConnection, fatPacket) + + def _init_engine(self, endpoint, callback, engine, concurrentConnections, requestsPerConnection, readFreq, maxQueueSize, timeout, maxRetriesPerRequest, idleTimeout, readCallback, readSize, resumeSSL, autoStart, explodeOnEarlyRead, warmLocalConnection, fatPacket): + raise NotImplementedError + + +class RequestEngine(_RequestEngineBase): + + def _init_engine(self, endpoint, callback, engine, concurrentConnections, requestsPerConnection, readFreq, maxQueueSize, timeout, maxRetriesPerRequest, idleTimeout, readCallback, readSize, resumeSSL, autoStart, explodeOnEarlyRead, warmLocalConnection, fatPacket): if(engine == Engine.BURP): self.engine = burp.BurpRequestEngine(endpoint, concurrentConnections, maxQueueSize, maxRetriesPerRequest, idleTimeout, callback, readCallback, True) elif(engine == Engine.BURP2): @@ -322,15 +331,15 @@ def applySetting(self, settingName, settingValue): def start(self, timeout=5): if self.autoStart or self.engine.attackState.get() != 0: - print 'The engine has already started - you no longer need to invoke engine.start() manually. If you prefer to invoke engine.start() manually, set autoStart=False in the constructor' + print('The engine has already started - you no longer need to invoke engine.start() manually. If you prefer to invoke engine.start() manually, set autoStart=False in the constructor') return self.engine.start(timeout) def complete(self, timeout=-1): self.engine.showStats(timeout) - def cancel(self): self.engine.cancel() + def completed(ignored): pass \ No newline at end of file diff --git a/resources/turbo_intruder.py b/resources/turbo_intruder.py index 43e4fce..a147c2e 100644 --- a/resources/turbo_intruder.py +++ b/resources/turbo_intruder.py @@ -268,8 +268,9 @@ class Engine: BURP2 = 4 SPIKE = 5 -class RequestEngine: - def __init__(self, endpoint, callback=None, engine=Engine.THREADED, concurrentConnections=50, requestsPerConnection=100, pipeline=False, maxQueueSize=100, timeout=10, maxRetriesPerRequest=3, idleTimeout=0, readCallback=None, readSize=1024, resumeSSL=True, autoStart=True, explodeOnEarlyRead=False, warmLocalConnection=True, fatPacket=False): +class RequestEngine(_RequestEngineBase): + + def _init_engine(self, endpoint, callback, engine, concurrentConnections, requestsPerConnection, readFreq, maxQueueSize, timeout, maxRetriesPerRequest, idleTimeout, readCallback, readSize, resumeSSL, autoStart, explodeOnEarlyRead, warmLocalConnection, fatPacket): self.endpoint = endpoint params = { "endpoint": endpoint, @@ -287,7 +288,7 @@ def queue(self, template, payloads=None, learn=0, callback=None, gate=None, labe payloads = [] elif not isinstance(payloads, list): payloads = [str(payloads)] - + params = { "template": template, "words": payloads, diff --git a/src/Python3Runner.kt b/src/Python3Runner.kt index e6ab809..2ece33f 100644 --- a/src/Python3Runner.kt +++ b/src/Python3Runner.kt @@ -60,7 +60,7 @@ class Python3Runner( "jsonrpc" to "2.0", "method" to "init", "params" to mapOf( - "script" to script, + "script" to (extractBaseClass() + "\n" + script), "req" to baseRequest, "rawReq" to java.util.Base64.getEncoder().encodeToString(rawRequest), "endpoint" to endpoint, @@ -330,6 +330,19 @@ class Python3Runner( return tmpFile.absolutePath } + fun extractBaseClass(): String { + val resource = Python3Runner::class.java.getResourceAsStream("/ScriptEnvironment.py") + ?: return "" + val content = resource.bufferedReader(Charsets.UTF_8).readText() + val startMarker = "class _RequestEngineBase:" + val endMarker = "class RequestEngine(_RequestEngineBase):" + val startIdx = content.indexOf(startMarker) + if (startIdx < 0) return "" + val endIdx = content.indexOf(endMarker, startIdx) + return if (endIdx > startIdx) content.substring(startIdx, endIdx).trimEnd() + else "" + } + fun isAvailable(): Boolean = try { findPython3(); true } catch (_: Exception) { false } } } From e6299ce36ea0ecff03b24dd9627a443869f271ed Mon Sep 17 00:00:00 2001 From: vichhka-git Date: Wed, 4 Mar 2026 21:09:18 +0700 Subject: [PATCH 5/6] Revert "fix: extract _RequestEngineBase to eliminate duplicate RequestEngine API signature" This reverts commit 93076cf5ce3a27214d5f2ea69ace4b3125ff04f4. --- resources/ScriptEnvironment.py | 15 +++------------ resources/turbo_intruder.py | 7 +++---- src/Python3Runner.kt | 15 +-------------- 3 files changed, 7 insertions(+), 30 deletions(-) diff --git a/resources/ScriptEnvironment.py b/resources/ScriptEnvironment.py index e2f3838..2ea299a 100644 --- a/resources/ScriptEnvironment.py +++ b/resources/ScriptEnvironment.py @@ -262,7 +262,7 @@ class Engine: BURP2 = 4 SPIKE = 5 -class _RequestEngineBase: +class RequestEngine: def __init__(self, endpoint, callback=None, engine=Engine.THREADED, concurrentConnections=50, requestsPerConnection=100, pipeline=False, maxQueueSize=100, timeout=10, maxRetriesPerRequest=3, idleTimeout=0, readCallback=None, readSize=1024, resumeSSL=True, autoStart=True, explodeOnEarlyRead=False, warmLocalConnection=True, fatPacket=False): concurrentConnections = int(concurrentConnections) @@ -284,15 +284,6 @@ def __init__(self, endpoint, callback=None, engine=Engine.THREADED, concurrentCo if(readCallback != None): print('Read callbacks are not supported in the Burp request engine. Try Engine.THREADED instead.') - self._init_engine(endpoint, callback, engine, concurrentConnections, requestsPerConnection, readFreq, maxQueueSize, timeout, maxRetriesPerRequest, idleTimeout, readCallback, readSize, resumeSSL, autoStart, explodeOnEarlyRead, warmLocalConnection, fatPacket) - - def _init_engine(self, endpoint, callback, engine, concurrentConnections, requestsPerConnection, readFreq, maxQueueSize, timeout, maxRetriesPerRequest, idleTimeout, readCallback, readSize, resumeSSL, autoStart, explodeOnEarlyRead, warmLocalConnection, fatPacket): - raise NotImplementedError - - -class RequestEngine(_RequestEngineBase): - - def _init_engine(self, endpoint, callback, engine, concurrentConnections, requestsPerConnection, readFreq, maxQueueSize, timeout, maxRetriesPerRequest, idleTimeout, readCallback, readSize, resumeSSL, autoStart, explodeOnEarlyRead, warmLocalConnection, fatPacket): if(engine == Engine.BURP): self.engine = burp.BurpRequestEngine(endpoint, concurrentConnections, maxQueueSize, maxRetriesPerRequest, idleTimeout, callback, readCallback, True) elif(engine == Engine.BURP2): @@ -331,15 +322,15 @@ def applySetting(self, settingName, settingValue): def start(self, timeout=5): if self.autoStart or self.engine.attackState.get() != 0: - print('The engine has already started - you no longer need to invoke engine.start() manually. If you prefer to invoke engine.start() manually, set autoStart=False in the constructor') + print 'The engine has already started - you no longer need to invoke engine.start() manually. If you prefer to invoke engine.start() manually, set autoStart=False in the constructor' return self.engine.start(timeout) def complete(self, timeout=-1): self.engine.showStats(timeout) + def cancel(self): self.engine.cancel() - def completed(ignored): pass \ No newline at end of file diff --git a/resources/turbo_intruder.py b/resources/turbo_intruder.py index a147c2e..43e4fce 100644 --- a/resources/turbo_intruder.py +++ b/resources/turbo_intruder.py @@ -268,9 +268,8 @@ class Engine: BURP2 = 4 SPIKE = 5 -class RequestEngine(_RequestEngineBase): - - def _init_engine(self, endpoint, callback, engine, concurrentConnections, requestsPerConnection, readFreq, maxQueueSize, timeout, maxRetriesPerRequest, idleTimeout, readCallback, readSize, resumeSSL, autoStart, explodeOnEarlyRead, warmLocalConnection, fatPacket): +class RequestEngine: + def __init__(self, endpoint, callback=None, engine=Engine.THREADED, concurrentConnections=50, requestsPerConnection=100, pipeline=False, maxQueueSize=100, timeout=10, maxRetriesPerRequest=3, idleTimeout=0, readCallback=None, readSize=1024, resumeSSL=True, autoStart=True, explodeOnEarlyRead=False, warmLocalConnection=True, fatPacket=False): self.endpoint = endpoint params = { "endpoint": endpoint, @@ -288,7 +287,7 @@ def queue(self, template, payloads=None, learn=0, callback=None, gate=None, labe payloads = [] elif not isinstance(payloads, list): payloads = [str(payloads)] - + params = { "template": template, "words": payloads, diff --git a/src/Python3Runner.kt b/src/Python3Runner.kt index 2ece33f..e6ab809 100644 --- a/src/Python3Runner.kt +++ b/src/Python3Runner.kt @@ -60,7 +60,7 @@ class Python3Runner( "jsonrpc" to "2.0", "method" to "init", "params" to mapOf( - "script" to (extractBaseClass() + "\n" + script), + "script" to script, "req" to baseRequest, "rawReq" to java.util.Base64.getEncoder().encodeToString(rawRequest), "endpoint" to endpoint, @@ -330,19 +330,6 @@ class Python3Runner( return tmpFile.absolutePath } - fun extractBaseClass(): String { - val resource = Python3Runner::class.java.getResourceAsStream("/ScriptEnvironment.py") - ?: return "" - val content = resource.bufferedReader(Charsets.UTF_8).readText() - val startMarker = "class _RequestEngineBase:" - val endMarker = "class RequestEngine(_RequestEngineBase):" - val startIdx = content.indexOf(startMarker) - if (startIdx < 0) return "" - val endIdx = content.indexOf(endMarker, startIdx) - return if (endIdx > startIdx) content.substring(startIdx, endIdx).trimEnd() - else "" - } - fun isAvailable(): Boolean = try { findPython3(); true } catch (_: Exception) { false } } } From 8db447fdef302ea67d33fac98c5ec0ec7fa985c3 Mon Sep 17 00:00:00 2001 From: vichhka-git Date: Wed, 4 Mar 2026 22:13:23 +0700 Subject: [PATCH 6/6] fix(issue3): add SYNC comments and AGENTS.md API sync rule for dual Python environments Addresses owner concern about RequestEngine.__init__ signature divergence between Jython (ScriptEnvironment.py) and Python 3 (turbo_intruder.py) stubs. Adds prominent SYNC comments in both Python files and all Kotlin callers pointing developers to each other. Documents the full update checklist in AGENTS.md under API SYNC RULE so any future contributor or AI agent knows exactly which files to patch when a Kotlin engine parameter changes. --- AGENTS.md | 23 +++++++++++++++++++++++ resources/ScriptEnvironment.py | 3 +++ resources/turbo_intruder.py | 3 +++ src/Python3Runner.kt | 4 ++++ src/fast-http.kt | 3 +++ 5 files changed, 36 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 247270b..cb69cc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,3 +149,26 @@ java -jar turbo-intruder-all.jar scriptFile baseRequestFile endpoint [baseInput] - `ThreadedRequestEngine` trusts all SSL certs (`TrustingTrustManager`) — intentional for pentest use - No CI pipeline, no linter config, no formatter config - Burp App Store metadata: `BappManifest.bmf`, `BappDescription.html` + +## API SYNC RULE + +**`RequestEngine.__init__` exists in TWO files and must always be identical:** + +| File | Runtime | Role | +|------|---------|------| +| `resources/ScriptEnvironment.py` | Jython 2.7 (inside JVM) | Injected before every user attack script in Burp; calls Kotlin/Java directly via Jython interop | +| `resources/turbo_intruder.py` | CPython 3.x (subprocess) | Extracted from JAR and executed as a standalone Python 3 process; sends JSON-RPC to Kotlin instead of calling Java | + +Both `RequestEngine.__init__` signatures must stay **byte-for-byte identical** (same parameter names, same order, same defaults). + +### When a Kotlin engine parameter changes (add / remove / rename): + +1. Update `RequestEngine.__init__` in **`resources/ScriptEnvironment.py`** +2. Update `RequestEngine.__init__` in **`resources/turbo_intruder.py`** — same change, same position +3. Update the engine constructor call body in `ScriptEnvironment.py` (the `burp.*` Java dispatch) +4. Update the `createEngine` RPC params in `turbo_intruder.py` +5. Update `handleCreateEngine()` in `src/Python3Runner.kt` to handle the new param over RPC +6. Rebuild: `JAVA_HOME=... ./gradlew fatjar` + +Both files contain a `# !! SYNC:` comment above `__init__` as a reminder. Do not remove those comments. + diff --git a/resources/ScriptEnvironment.py b/resources/ScriptEnvironment.py index 2ea299a..24e6677 100644 --- a/resources/ScriptEnvironment.py +++ b/resources/ScriptEnvironment.py @@ -264,6 +264,9 @@ class Engine: class RequestEngine: + # !! SYNC: If you change this __init__ signature (add/remove/rename parameters or change defaults), + # !! you MUST make the identical change in RequestEngine.__init__ in resources/turbo_intruder.py. + # !! The Kotlin parity check in Python3Runner.kt will alert if they diverge at load time. def __init__(self, endpoint, callback=None, engine=Engine.THREADED, concurrentConnections=50, requestsPerConnection=100, pipeline=False, maxQueueSize=100, timeout=10, maxRetriesPerRequest=3, idleTimeout=0, readCallback=None, readSize=1024, resumeSSL=True, autoStart=True, explodeOnEarlyRead=False, warmLocalConnection=True, fatPacket=False): concurrentConnections = int(concurrentConnections) requestsPerConnection = int(requestsPerConnection) diff --git a/resources/turbo_intruder.py b/resources/turbo_intruder.py index 43e4fce..12f95ff 100644 --- a/resources/turbo_intruder.py +++ b/resources/turbo_intruder.py @@ -269,6 +269,9 @@ class Engine: SPIKE = 5 class RequestEngine: + # !! SYNC: If you change this __init__ signature (add/remove/rename parameters or change defaults), + # !! you MUST make the identical change in RequestEngine.__init__ in resources/ScriptEnvironment.py. + # !! See AGENTS.md § API SYNC RULE for full guidance. def __init__(self, endpoint, callback=None, engine=Engine.THREADED, concurrentConnections=50, requestsPerConnection=100, pipeline=False, maxQueueSize=100, timeout=10, maxRetriesPerRequest=3, idleTimeout=0, readCallback=None, readSize=1024, resumeSSL=True, autoStart=True, explodeOnEarlyRead=False, warmLocalConnection=True, fatPacket=False): self.endpoint = endpoint params = { diff --git a/src/Python3Runner.kt b/src/Python3Runner.kt index e6ab809..b54a424 100644 --- a/src/Python3Runner.kt +++ b/src/Python3Runner.kt @@ -320,6 +320,10 @@ class Python3Runner( } + // !! SYNC: turbo_intruder.py is the Python 3 RPC stub. If RequestEngine.__init__ signature + // !! changes (engine params added/removed), update BOTH resources/turbo_intruder.py AND + // !! resources/ScriptEnvironment.py. See AGENTS.md § API SYNC RULE for full guidance. + fun extractStub(): String { val resource = Python3Runner::class.java.getResourceAsStream("/turbo_intruder.py") ?: throw IllegalStateException("turbo_intruder.py not found in JAR resources") diff --git a/src/fast-http.kt b/src/fast-http.kt index ce3baff..b4f72d2 100644 --- a/src/fast-http.kt +++ b/src/fast-http.kt @@ -27,6 +27,9 @@ import kotlin.concurrent.thread class Scripts() { companion object { + // !! SYNC: ScriptEnvironment.py is the Jython API environment. If RequestEngine.__init__ + // !! signature changes here (engine params), update BOTH resources/ScriptEnvironment.py + // !! AND resources/turbo_intruder.py. See Python3Runner.checkApiParity() for auto-detection. val SCRIPTENVIRONMENT = Scripts::class.java.getResource("/ScriptEnvironment.py").readText() val SAMPLEBURPSCRIPT = Scripts::class.java.getResource("/examples/default.py").readText()