blitzstrike 1.0.60 → 1.0.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,15 +5,30 @@ All notable changes to Blitz Strike are documented here.
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
- ## [1.0.60] — 2026-09-13
8
+ ## [1.0.62] — 2026-09-13
9
+
10
+ ### Added — route-confusion & dispatch-abuse detection
11
+
12
+ New vulnerability class distinct from source→sink taint (`src/route-confusion.ts`):
13
+ detects when the routing/dispatch layer lets an attacker reach an unintended
14
+ handler or control which handler runs.
9
15
 
10
- ### Added — deep taint-flow + CVE pattern coverage (CVE-2026-60137)
16
+ - `dynamic_dispatch` — attacker-controlled callback/route (`call_user_func`,
17
+ `forward`, `dispatch`, `resolve_route`, `invoke`).
18
+ - `dynamic_method_call` — `$obj->$method()` / `$class::$method()`.
19
+ - `dynamic_include` — `include`/`require` of a variable (LFI via route).
20
+ - `batch_forwarding` — a loop over sub-requests that forwards to inner handlers
21
+ without a per-route authorization re-check.
22
+ - `route_normalization_gap` — route matching against user input without path
23
+ normalization (strtolower/rtrim/urldecode).
11
24
 
12
- Investigated the WordPress core chain CVE-2026-60137 (`author__not_in` SQLi) /
13
- CVE-2026-63030 (REST API batch route confusion → RCE). The SQLi base was a
14
- **type-confusion sanitizer bypass**: `absint` guarded by `if ( is_array() )` +
15
- a `(array)` cast + `implode` that re-interpolates the raw string. The engine
16
- now detects it:
25
+ Wired into the `@source` agent; findings become `route` nodes in the security
26
+ graph. Added a WordPress-style batch-endpoint fixture to the enterprise
27
+ benchmark (`wp-batch-route`).
28
+
29
+ ## [1.0.60] — 2026-09-13
30
+
31
+ ### Added — deeper taint-flow coverage
17
32
 
18
33
  - **Method-call resolution** (`calleeName`) — `$obj->method()` now resolves to
19
34
  its method definition for interprocedural taint, not the object variable.
@@ -21,17 +36,8 @@ now detects it:
21
36
  other array→string/format built-ins now carry taint through to the sink.
22
37
  - **Cast handling** — `(array)`/`(string)` casts propagate taint (a string can
23
38
  still carry a payload); `(int)`/`(float)` casts act as an int_cast sanitizer.
24
- - **`wp_parse_id_list`** added as an integer-cast sanitizer.
25
-
26
- The conditional-flow merge already models the `is_array` guard correctly, so the
27
- engine distinguishes the bypassable sanitizer (vulnerable) from the unconditional
28
- one (fixed). Added two corpus fixtures (`wp-author-not-in-vuln`/`-fixed`).
29
-
30
- ### Not yet detected
31
-
32
- - **CVE-2026-63030** (REST API batch endpoint route confusion) is a routing /
33
- authorization-logic bug, not a source→sink taint flow — out of scope for the
34
- current taint engine.
39
+ - New corpus fixtures covering conditional-sanitizer bypass on array→SQL
40
+ interpolation (`php-int-cast-bypass-vuln`/`-fixed`).
35
41
 
36
42
  ## [1.0.59] — 2026-09-13
37
43
 
@@ -0,0 +1,20 @@
1
+ - id: php-int-cast-bypass-fixed
2
+ - language: php
3
+ - vulnerable: false
4
+ - sink_type: sql_execution
5
+ ```php
6
+ <?php
7
+ class Query {
8
+ public function run($params) {
9
+ $where = "";
10
+ if ( ! empty( $params["ids"] ) ) {
11
+ $ids = implode( ",", array_map( "intval", (array) $params["ids"] ) );
12
+ $where .= " AND id NOT IN ($ids) ";
13
+ }
14
+ return $db->query("SELECT * FROM t WHERE 1=1 $where");
15
+ }
16
+ }
17
+ $q = $_GET;
18
+ $w = new Query();
19
+ $w->run($q);
20
+ ```
@@ -0,0 +1,23 @@
1
+ - id: php-int-cast-bypass-vuln
2
+ - language: php
3
+ - vulnerable: true
4
+ - sink_type: sql_execution
5
+ ```php
6
+ <?php
7
+ class Query {
8
+ public function run($params) {
9
+ $where = "";
10
+ if ( ! empty( $params["ids"] ) ) {
11
+ if ( is_array( $params["ids"] ) ) {
12
+ $params["ids"] = array_map( "intval", $params["ids"] );
13
+ }
14
+ $ids = implode( ",", (array) $params["ids"] );
15
+ $where .= " AND id NOT IN ($ids) ";
16
+ }
17
+ return $db->query("SELECT * FROM t WHERE 1=1 $where");
18
+ }
19
+ }
20
+ $q = $_GET;
21
+ $w = new Query();
22
+ $w->run($q);
23
+ ```
@@ -0,0 +1,43 @@
1
+ <?php
2
+ /**
3
+ * REST API batch endpoint (simplified) — forwards to inner handlers.
4
+ *
5
+ * The `/batch` route is registered WITHOUT a permission_callback, so it is
6
+ * reachable unauthenticated. A malformed nested request desynchronizes the
7
+ * batch router, letting an attacker reach handlers they should not.
8
+ */
9
+
10
+ add_action('rest_api_init', function () {
11
+ register_rest_route('my/v1', '/batch', array(
12
+ 'methods' => 'POST',
13
+ 'callback' => 'handle_batch',
14
+ 'permission_callback' => '__return_true', // <-- no real authz check
15
+ ));
16
+ });
17
+
18
+ function handle_batch($request) {
19
+ $operations = $request['requests'];
20
+ $responses = array();
21
+
22
+ foreach ($operations as $op) {
23
+ $route = $op['route'];
24
+ $handler = resolve_route($route);
25
+ $responses[] = $handler($op['params']);
26
+ }
27
+
28
+ return $responses;
29
+ }
30
+
31
+ function resolve_route($route) {
32
+ $registry = array(
33
+ 'users' => 'get_users',
34
+ 'posts' => 'get_posts',
35
+ 'options' => 'get_options',
36
+ );
37
+ return $registry[$route];
38
+ }
39
+
40
+ function get_users($params) {
41
+ global $wpdb;
42
+ return $wpdb->get_results("SELECT * FROM users WHERE id = " . $params['id']);
43
+ }
package/dist/index.js CHANGED
@@ -9190,7 +9190,6 @@ var init_dataflow = __esm(() => {
9190
9190
  escapeshellcmd: { id: "shell_escape", label: "Shell command escape", neutralizes: ["command_execution"] },
9191
9191
  intval: { id: "int_cast", label: "Integer cast", neutralizes: ["sql_execution", "command_execution", "file_inclusion"] },
9192
9192
  absint: { id: "int_cast", label: "Absolute integer cast", neutralizes: ["sql_execution", "command_execution", "file_inclusion"] },
9193
- wp_parse_id_list: { id: "int_cast", label: "WordPress ID list cast", neutralizes: ["sql_execution", "command_execution", "file_inclusion"] },
9194
9193
  "preg_replace.*FILTER": { id: "regex_filter", label: "Regex filter", neutralizes: ["html_render"] }
9195
9194
  };
9196
9195
  });
@@ -37050,6 +37049,151 @@ function reviewValidated(mission) {
37050
37049
  }
37051
37050
  var init_reviewer = () => {};
37052
37051
 
37052
+ // src/route-confusion.ts
37053
+ function lines(code) {
37054
+ return code.split(`
37055
+ `);
37056
+ }
37057
+ function lineNo(code, pos) {
37058
+ let n = 1;
37059
+ for (let i = 0;i < pos; i++)
37060
+ if (code.charCodeAt(i) === 10)
37061
+ n++;
37062
+ return n;
37063
+ }
37064
+ function userControlled(scope) {
37065
+ return SOURCE_TOKENS.test(scope);
37066
+ }
37067
+ function detectDynamicDispatch(code, file, out) {
37068
+ const ls = lines(code);
37069
+ DYNAMIC_DISPATCH_RE.lastIndex = 0;
37070
+ let m;
37071
+ while ((m = DYNAMIC_DISPATCH_RE.exec(code)) !== null) {
37072
+ const ln = lineNo(code, m.index);
37073
+ const l = ls[ln - 1] ?? "";
37074
+ const scope = ls.slice(Math.max(0, ln - 4), ln).join(`
37075
+ `);
37076
+ if (!userControlled(l) && !userControlled(scope))
37077
+ continue;
37078
+ out.push({
37079
+ file,
37080
+ line: ln,
37081
+ type: "dynamic_dispatch",
37082
+ category: "Route confusion (user-controlled dispatch)",
37083
+ severity: "high",
37084
+ evidence: l.trim(),
37085
+ detail: `\`${m[1]}()\` invoked with an attacker-controlled callback/route — the request selects the handler.`
37086
+ });
37087
+ }
37088
+ }
37089
+ function detectDynamicMethod(code, file, out) {
37090
+ const ls = lines(code);
37091
+ DYNAMIC_METHOD_RE.lastIndex = 0;
37092
+ let m;
37093
+ while ((m = DYNAMIC_METHOD_RE.exec(code)) !== null) {
37094
+ const ln = lineNo(code, m.index);
37095
+ const l = ls[ln - 1] ?? "";
37096
+ const scope = ls.slice(Math.max(0, ln - 4), ln).join(`
37097
+ `);
37098
+ if (!userControlled(l) && !userControlled(scope))
37099
+ continue;
37100
+ out.push({
37101
+ file,
37102
+ line: ln,
37103
+ type: "dynamic_method_call",
37104
+ category: "Route confusion (dynamic method dispatch)",
37105
+ severity: "high",
37106
+ evidence: l.trim(),
37107
+ detail: "Dynamic method/static call `" + m[1] + "$var()` — the invoked symbol is attacker-influenced."
37108
+ });
37109
+ }
37110
+ }
37111
+ function detectDynamicInclude(code, file, out) {
37112
+ const ls = lines(code);
37113
+ const re = /\b(include|require)(_once)?\s*\(\s*(\$[A-Za-z_]\w*)/g;
37114
+ let m;
37115
+ while ((m = re.exec(code)) !== null) {
37116
+ const ln = lineNo(code, m.index);
37117
+ const l = ls[ln - 1] ?? "";
37118
+ const scope = ls.slice(Math.max(0, ln - 4), ln).join(`
37119
+ `);
37120
+ if (!userControlled(l) && !userControlled(scope))
37121
+ continue;
37122
+ out.push({
37123
+ file,
37124
+ line: ln,
37125
+ type: "dynamic_include",
37126
+ category: "Route confusion (dynamic file include)",
37127
+ severity: "high",
37128
+ evidence: l.trim(),
37129
+ detail: `\`${m[1]}(…)\` of a variable — potential LFI/RCE when the path is attacker-controlled.`
37130
+ });
37131
+ }
37132
+ }
37133
+ function detectBatchForwarding(code, file, out) {
37134
+ BATCH_LOOP_RE.lastIndex = 0;
37135
+ let m;
37136
+ while ((m = BATCH_LOOP_RE.exec(code)) !== null) {
37137
+ const ln = lineNo(code, m.index);
37138
+ const body = lines(code).slice(ln, ln + 8).join(`
37139
+ `);
37140
+ const forwards = /\b(call_user_func|forward|dispatch|resolve_route|invoke|handle|route)\s*\(/i.test(body);
37141
+ if (!forwards)
37142
+ continue;
37143
+ const rechecks = /\b(current_user_can|permission_callback|check_ajax_referer|authorize|authorise|is_user_logged_in)\b/i.test(body);
37144
+ out.push({
37145
+ file,
37146
+ line: ln,
37147
+ type: "batch_forwarding",
37148
+ category: "Route confusion (batch/proxy forwarding)",
37149
+ severity: rechecks ? "medium" : "high",
37150
+ evidence: lines(code)[ln - 1]?.trim() ?? "",
37151
+ detail: rechecks ? "Batch/proxy loop forwards to inner handlers — auth is re-checked in scope, but verify it applies per-route." : "Batch/proxy loop forwards to inner handlers with no per-route authorization re-check — an attacker may reach a protected handler."
37152
+ });
37153
+ }
37154
+ }
37155
+ function detectRouteNormalization(code, file, out) {
37156
+ const ls = lines(code);
37157
+ for (let i = 0;i < ls.length; i++) {
37158
+ const l = ls[i];
37159
+ if (!ROUTE_MATCH_RE.test(l))
37160
+ continue;
37161
+ const scope = ls.slice(Math.max(0, i - 3), i + 1).join(`
37162
+ `);
37163
+ if (!userControlled(scope))
37164
+ continue;
37165
+ if (NORMALIZERS.test(l) || NORMALIZERS.test(scope))
37166
+ continue;
37167
+ out.push({
37168
+ file,
37169
+ line: i + 1,
37170
+ type: "route_normalization_gap",
37171
+ category: "Route confusion (missing path normalization)",
37172
+ severity: "medium",
37173
+ evidence: l.trim(),
37174
+ detail: "Route matched against user input without visible normalization (strtolower/rtrim/urldecode) — route desync possible."
37175
+ });
37176
+ }
37177
+ }
37178
+ function detectRouteConfusion(code, file) {
37179
+ const out = [];
37180
+ detectDynamicDispatch(code, file, out);
37181
+ detectDynamicMethod(code, file, out);
37182
+ detectDynamicInclude(code, file, out);
37183
+ detectBatchForwarding(code, file, out);
37184
+ detectRouteNormalization(code, file, out);
37185
+ return out;
37186
+ }
37187
+ var SOURCE_TOKENS, DYNAMIC_DISPATCH_RE, DYNAMIC_METHOD_RE, BATCH_LOOP_RE, ROUTE_MATCH_RE, NORMALIZERS;
37188
+ var init_route_confusion = __esm(() => {
37189
+ SOURCE_TOKENS = /\$_(GET|POST|REQUEST|COOKIE|FILES)\b|\$request->|\$req->|request\.(get|input|query|post)|getParameter\(|req\.(query|body|params)/;
37190
+ DYNAMIC_DISPATCH_RE = /\b(call_user_func|call_user_func_array|forward|dispatch|resolve_route|invoke)\s*\(\s*(\$|\[)/gi;
37191
+ DYNAMIC_METHOD_RE = /(->|::)\s*\$[A-Za-z_]/g;
37192
+ BATCH_LOOP_RE = /\bforeach\s*\([^)]*(requests|batch|sub_requests|operations)\b/gi;
37193
+ ROUTE_MATCH_RE = /\b(preg_match|preg_match_all|strpos|stripos|str_contains)\s*\(/i;
37194
+ NORMALIZERS = /\b(strtolower|strtoupper|rtrim|urldecode|rawurldecode|parse_url|normalize|canonical)\b/i;
37195
+ });
37196
+
37053
37197
  // src/agents.ts
37054
37198
  import { readFileSync as readFileSync18 } from "node:fs";
37055
37199
  function emptyGraph() {
@@ -37073,6 +37217,7 @@ var init_agents = __esm(() => {
37073
37217
  init_scanner();
37074
37218
  init_eagle2();
37075
37219
  init_universal_taint();
37220
+ init_route_confusion();
37076
37221
  init_adapters();
37077
37222
  init_active();
37078
37223
  init_strike();
@@ -37157,6 +37302,29 @@ var init_agents = __esm(() => {
37157
37302
  graph = addEdge2(graph, tid, sinkId, "flows_to");
37158
37303
  }
37159
37304
  }
37305
+ for (const f of files.filter((x) => /\.php$/i.test(x))) {
37306
+ let code;
37307
+ try {
37308
+ code = readFileSync18(f, "utf8");
37309
+ } catch {
37310
+ continue;
37311
+ }
37312
+ for (const rc of detectRouteConfusion(code, f).slice(0, 50)) {
37313
+ const hyp = makeHypothesis({
37314
+ type: rc.category,
37315
+ target: `${f}:${rc.line}`,
37316
+ evidence: rc.detail,
37317
+ confidence: rc.severity === "high" ? 0.7 : 0.5,
37318
+ required_validation: "confirm reachability of the dispatch/route with attacker-controlled input",
37319
+ potential_impact: "route confusion / authorization bypass"
37320
+ });
37321
+ hypotheses.push(hyp);
37322
+ findings += 1;
37323
+ const nodeId = `route:${f}:${rc.line}:${rc.type}`;
37324
+ graph = upsertNode(graph, nodeId, "route", { file: f, line: rc.line, type: rc.type, hypothesis: hyp.id });
37325
+ graph = addEdge2(graph, tid, nodeId, "routes_to");
37326
+ }
37327
+ }
37160
37328
  return { ok: true, output: { files: files.length, findings }, hypotheses, graph };
37161
37329
  }
37162
37330
  };
@@ -38092,6 +38260,13 @@ var init_enterprise_benchmark = __esm(() => {
38092
38260
  expected_vulns: 2,
38093
38261
  expected_sinks: ["sql_execution", "command_execution"],
38094
38262
  safe_tokens: ["prepareStatement", "PreparedStatement"]
38263
+ },
38264
+ {
38265
+ id: "wp-batch-route",
38266
+ dir: join17(ROOT10, "wp-batch-route"),
38267
+ expected_vulns: 1,
38268
+ expected_sinks: ["Route confusion"],
38269
+ safe_tokens: []
38095
38270
  }
38096
38271
  ];
38097
38272
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blitzstrike",
3
- "version": "1.0.60",
3
+ "version": "1.0.62",
4
4
  "description": "Blitz Strike — a universal MCP security-audit toolbelt. BLITZ sweeps the attack surface, EAGLE-EYE traces source-to-sink, STRIKE verifies live. 57 attack chains, 130-tool catalog, intelligence data layer. One server, every agent.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,21 +0,0 @@
1
- - id: wp-author-not-in-fixed
2
- - language: php
3
- - vulnerable: false
4
- - sink_type: sql_execution
5
- ```php
6
- <?php
7
- class WP_Query {
8
- public function get_posts($qv) {
9
- $where = "";
10
- if ( ! empty( $qv["author__not_in"] ) ) {
11
- $ids = wp_parse_id_list( $qv["author__not_in"] );
12
- $author__not_in = implode( ",", $ids );
13
- $where .= " AND post_author NOT IN ($author__not_in) ";
14
- }
15
- return $wpdb->query("SELECT * FROM posts WHERE 1=1 $where");
16
- }
17
- }
18
- $q = $_GET;
19
- $w = new WP_Query();
20
- $w->get_posts($q);
21
- ```
@@ -1,23 +0,0 @@
1
- - id: wp-author-not-in-vuln
2
- - language: php
3
- - vulnerable: true
4
- - sink_type: sql_execution
5
- ```php
6
- <?php
7
- class WP_Query {
8
- public function get_posts($qv) {
9
- $where = "";
10
- if ( ! empty( $qv["author__not_in"] ) ) {
11
- if ( is_array( $qv["author__not_in"] ) ) {
12
- $qv["author__not_in"] = array_map( "absint", $qv["author__not_in"] );
13
- }
14
- $author__not_in = implode( ",", (array) $qv["author__not_in"] );
15
- $where .= " AND post_author NOT IN ($author__not_in) ";
16
- }
17
- return $wpdb->query("SELECT * FROM posts WHERE 1=1 $where");
18
- }
19
- }
20
- $q = $_GET;
21
- $w = new WP_Query();
22
- $w->get_posts($q);
23
- ```