diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cb69cc2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,174 @@ +# 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` + +## 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/build.gradle b/build.gradle index d84e5b2..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 } @@ -58,3 +59,8 @@ task fatJar(type: Jar) { with jar } +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + jvmTarget = "21" + } +} 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 new file mode 100644 index 0000000..12f95ff --- /dev/null +++ b/resources/turbo_intruder.py @@ -0,0 +1,452 @@ +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: + # !! 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 = { + "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/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/Python3Runner.kt b/src/Python3Runner.kt new file mode 100644 index 0000000..b54a424 --- /dev/null +++ b/src/Python3Runner.kt @@ -0,0 +1,341 @@ +package burp + +import com.google.gson.Gson +import com.google.gson.JsonParser +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 val gson = Gson() + + 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() } + } + + 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 + } + + + + companion object { + fun findPython3(): String { + + val configured = Utils.callbacks?.loadExtensionSetting("python3Path") + 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)." + ) + } + + + // !! 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") + 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 } + } +} + + diff --git a/src/fast-http.kt b/src/fast-http.kt index 3ccbaa8..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() @@ -287,6 +290,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 +306,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 +447,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) + } } } }