Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ jobs:
- name: Confirm fat JAR
run: |
set -e
test -f target/burp-api-scanner-2.0.1.jar
ls -lh target/burp-api-scanner-2.0.1.jar
test -f target/burp-api-scanner-2.1.0.jar
ls -lh target/burp-api-scanner-2.1.0.jar

- name: Upload JAR
uses: actions/upload-artifact@v4
with:
name: burp-api-scanner-${{ github.sha }}
path: target/burp-api-scanner-2.0.1.jar
path: target/burp-api-scanner-2.1.0.jar
if-no-files-found: error
retention-days: 30
8 changes: 4 additions & 4 deletions BURP_DAST_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ The extension is shipped as a single fat JAR. Build with:
mvn clean package -DskipTests
```

Output: `target/burp-api-scanner-2.0.1.jar`.
Output: `target/burp-api-scanner-2.1.0.jar`.

In DAST:

1. **Settings → Extensions → Add extension**
2. Upload `burp-api-scanner-2.0.1.jar`
2. Upload `burp-api-scanner-2.1.0.jar`
3. Enable the extension

There is no per-DAST configuration — once loaded and enabled, the
Expand All @@ -35,7 +35,7 @@ healthy load it reads:

```
====================================
Advanced API Security Scanner v2.0.1
Advanced API Security Scanner v2.1.0
OWASP API Security Top 10 (2023) coverage
Edition: Burp Suite DAST
AI features: enabled (or "disabled" if Burp AI is off)
Expand Down Expand Up @@ -101,7 +101,7 @@ Kill switches (JVM system properties on the DAST process):
Before running production scans:

- [ ] Extension JAR loaded and enabled in Settings → Extensions
- [ ] Banner shows `v2.0.1`, the correct `Edition:`, and the expected
- [ ] Banner shows `v2.1.0`, the correct `Edition:`, and the expected
`AI features:` state
- [ ] Scan configuration includes Active + Passive audit (passive-only
will not surface any API5/API7 findings)
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export PATH="$JAVA_HOME/bin:$PATH"
mvn clean package -DskipTests
```

Output: `target/burp-api-scanner-2.0.1.jar` (~370 KB fat JAR).
Output: `target/burp-api-scanner-2.1.0.jar` (~370 KB fat JAR).

Load in Burp via **Extensions → Installed → Add → Java**.

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ registered only under Professional.
mvn clean package -DskipTests
```

Produces `target/burp-api-scanner-2.0.1.jar`. Load via **Extensions →
Produces `target/burp-api-scanner-2.1.0.jar`. Load via **Extensions →
Installed → Add → Java**.

Requires JDK 17+ (Montoya API requirement) and Maven 3.6+.
Expand All @@ -25,7 +25,7 @@ The banner in the extension's Output tab will look like:

```
====================================
Advanced API Security Scanner v2.0.1
Advanced API Security Scanner v2.1.0
OWASP API Security Top 10 (2023) coverage
Edition: Burp Suite Professional
AI features: enabled
Expand Down
26 changes: 20 additions & 6 deletions VALIDATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,31 @@ fixing?". Triage if needed, then prioritise.
| Finding | Why it's CERTAIN |
|---|---|
| **JWT `alg: none`** | Token verification is bypassed — any forged token is accepted. |
| **SQL injection (error-based)** | A SQL engine error string in the response proves untrusted input reached the SQL parser. |
| **Command injection** | Output of `whoami` / `ls` / etc. in the response proves shell execution. |
| **SSRF to cloud IMDS** | Cloud-metadata content in the response proves the server fetched the attacker-supplied URL. |
| **Reflected XSS** | The unencoded payload appears in the response body. |
| **API over HTTP** | The URL scheme is `http://`; transport is plaintext. |
| **TRACE method enabled** | TRACE responded with 200 — Cross-Site Tracing is reachable. |
| **SQL injection (error-based)** | A SQL engine error string *not present in the baseline* appeared after the payload — input reached the SQL parser. |
| **Command injection** | Command output (`root:`, `/bin/`, …) *new vs the baseline* appeared after the payload — the OS executed it. |
| **SSRF to cloud IMDS** | Cloud-metadata content appeared in a 2xx response and was absent from the baseline — the server fetched the attacker URL. |
| **TRACE method enabled** | TRACE returned 200 **and echoed our marker header back** — Cross-Site Tracing is confirmed, not just assumed. |
| **API version disclosed in header** | The header value is itself the disclosure. |

For these: no manual validation needed. Confirm scope ownership, then
fix.

> **Note on baseline diffing (v2.1.0+).** The injection and SSRF checks
> now only fire when the marker is *new versus the unmutated baseline
> response* — a marker that already appears in docs, error pages, or
> static content no longer triggers a finding. This removes a class of
> false positive that earlier versions produced.

A few findings that look "certain" are deliberately reported a notch
lower because a single response can't fully prove exploitability:

- **Reflected XSS** — reported **Firm**, and only when the response
Content-Type is HTML. A JSON API echoing the payload as a string
value is not exploitable, so it is no longer flagged.
- **API over HTTP** — reported **Firm**, and only on a 2xx cleartext
response. A 3xx redirect to the `https://` equivalent is correct
enforcement and is suppressed.

---

## FIRM — strong evidence, context-dependent
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

<groupId>com.security</groupId>
<artifactId>burp-api-scanner</artifactId>
<version>2.0.1</version>
<version>2.1.0</version>
<packaging>jar</packaging>

<name>Advanced API Security Scanner</name>
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/security/burp/BurpExtender.java
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ private void registerUnloadingHandler(MontoyaApi api,

private void logBanner(MontoyaApi api, BurpSuiteEdition edition, boolean aiAvailable) {
api.logging().logToOutput("====================================");
api.logging().logToOutput(EXTENSION_NAME + " v2.0.1");
api.logging().logToOutput(EXTENSION_NAME + " v2.1.0");
api.logging().logToOutput("OWASP API Security Top 10 (2023) coverage");
api.logging().logToOutput("Edition: " + edition.displayName());
api.logging().logToOutput("AI features: " + (aiAvailable ? "enabled" : "disabled"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.security.burp.checks.AbstractActiveCheck;
import com.security.burp.util.HttpUtils;
import com.security.burp.util.IssueBuilder;

import java.nio.charset.StandardCharsets;
Expand Down Expand Up @@ -118,6 +119,10 @@ private static boolean hasBasicAuth(HttpRequest request) {

private static boolean hasApiKeyOverHttp(HttpRequest request) {
if (request.url() == null || !request.url().startsWith("http://")) return false;
// Loopback HTTP has no network intermediary, so the key never leaves
// the local machine — flagging http://localhost dev endpoints High is
// a false positive. Skip loopback hosts.
if (HttpUtils.isLoopbackHost(request.httpService().host())) return false;
for (HttpHeader header : request.headers()) {
if (header.name() != null
&& API_KEY_HEADER_NAMES.contains(header.name().toLowerCase(Locale.ROOT))) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import burp.api.montoya.scanner.audit.insertionpoint.AuditInsertionPoint;
import burp.api.montoya.scanner.audit.issues.AuditIssue;
import com.security.burp.checks.AbstractActiveCheck;
import com.security.burp.util.HttpUtils;
import com.security.burp.util.IssueBuilder;

import java.util.ArrayList;
Expand Down Expand Up @@ -77,9 +78,20 @@ protected List<AuditIssue> audit(HttpRequestResponse rr, AuditInsertionPoint ip,
if (!hasObjectIdentifier(rr.request().url())) return List.of();

List<AuditIssue> issues = new ArrayList<>();
addIfFound(issues, tryIdManipulation(rr, http), "id-manipulation", "Critical");

// ID-manipulation and enumeration are only meaningful on an
// AUTHENTICATED request: BOLA means reaching another principal's
// object despite being logged in as someone else. On an
// unauthenticated request a 2xx for any ID just means the endpoint
// is public — not a vulnerability. (The missing-auth case is covered
// separately by the unauthenticated test below.) Without this guard
// the check fires Critical on every public /articles/{id} endpoint.
boolean authenticated = hasAnyAuthHeader(rr.request());
if (authenticated) {
addIfFound(issues, tryIdManipulation(rr, http), "id-manipulation", "Critical");
if (idEnumerable(rr, http)) issues.add(buildEnumerationIssue(rr));
}
addIfFound(issues, tryUnauthenticated(rr, http), "unauthenticated", "Critical");
if (idEnumerable(rr, http)) issues.add(buildEnumerationIssue(rr));
return issues;
}

Expand Down Expand Up @@ -170,8 +182,13 @@ private HttpRequestResponse sendWithReplacedId(HttpRequestResponse rr,
private HttpRequestResponse sendIfSuccess(HttpRequest request, Http http) {
try {
HttpRequestResponse response = http.sendRequest(request);
return (response != null && response.hasResponse() && isSuccess(response.response().statusCode()))
? response : null;
if (response == null || !response.hasResponse()) return null;
if (!isSuccess(response.response().statusCode())) return null;
// A 200 carrying an error/"not found"/"forbidden" body is the
// server refusing access with a sloppy status code — not a
// successful object read. Don't treat it as BOLA.
if (HttpUtils.looksRejected(response.response())) return null;
return response;
} catch (Exception e) {
api.logging().logToError("[BOLA] send failed: " + e.getMessage());
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import burp.api.montoya.scanner.audit.insertionpoint.AuditInsertionPoint;
import burp.api.montoya.scanner.audit.issues.AuditIssue;
import com.security.burp.checks.AbstractActiveCheck;
import com.security.burp.util.HttpUtils;
import com.security.burp.util.IssueBuilder;

import java.util.ArrayList;
Expand Down Expand Up @@ -139,8 +140,13 @@ private static HttpRequest removeHeaders(HttpRequest request, Set<String> namesL
private HttpRequestResponse sendOrNull(HttpRequest request, Http http) {
try {
HttpRequestResponse response = http.sendRequest(request);
return (response != null && response.hasResponse() && isSuccess(response.response().statusCode()))
? response : null;
if (response == null || !response.hasResponse()) return null;
if (!isSuccess(response.response().statusCode())) return null;
// A 2xx whose body is an error/"unauthorized"/"forbidden" message
// means authorization actually held (the server just used a sloppy
// status code). Don't count it as the privileged action succeeding.
if (HttpUtils.looksRejected(response.response())) return null;
return response;
} catch (Exception e) {
api.logging().logToError("[Function Level Auth] Send failed: " + e.getMessage());
return null;
Expand Down
60 changes: 51 additions & 9 deletions src/main/java/com/security/burp/checks/active/InjectionCheck.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import burp.api.montoya.http.Http;
import burp.api.montoya.http.message.HttpRequestResponse;
import burp.api.montoya.http.message.requests.HttpRequest;
import burp.api.montoya.http.message.HttpHeader;
import burp.api.montoya.http.message.responses.HttpResponse;
import burp.api.montoya.scanner.audit.insertionpoint.AuditInsertionPoint;
import burp.api.montoya.scanner.audit.issues.AuditIssue;
Expand Down Expand Up @@ -94,12 +95,14 @@ private static void addFirstFinding(List<AuditIssue> sink, AuditIssue finding) {
private AuditIssue runSql(HttpRequestResponse rr, AuditInsertionPoint ip, Http http) {
return runFamily(rr, ip, http,
InjectionPayloads.SQL,
(response, payload) -> bodyContainsAny(response, InjectionPayloads.SQL_ERROR_MARKERS)
// Only fire when the SQL-error marker is NEW vs the baseline —
// an error page that always contains "syntax error" isn't proof.
(response, payload) -> newMarkerVsBaseline(rr, response, InjectionPayloads.SQL_ERROR_MARKERS)
? "Critical" : null,
(base, evidence, payload, severity) -> buildIssue(base, evidence, ip, payload,
"API2:2023 - Broken Authentication (SQL Injection)",
"Server response carried a SQL engine error after the payload was " +
"delivered through this insertion point.",
"The payload introduced a SQL engine error that was NOT present in the " +
"baseline response — strong evidence the input reaches a SQL parser.",
severity, "Firm", SQL_BACKGROUND));
}

Expand Down Expand Up @@ -154,29 +157,37 @@ private AuditIssue buildNoSqlIssue(HttpRequestResponse base,
private AuditIssue runCommand(HttpRequestResponse rr, AuditInsertionPoint ip, Http http) {
return runFamily(rr, ip, http,
InjectionPayloads.COMMAND,
// Command-output markers (root:, /bin/, ...) appear constantly in
// docs and config dumps. Only fire when the marker is NEW vs the
// baseline — i.e. the payload actually produced it.
(response, payload) -> {
boolean output = bodyContainsAny(response, InjectionPayloads.COMMAND_OUTPUT_MARKERS);
boolean error = bodyContainsAny(response, InjectionPayloads.COMMAND_ERROR_MARKERS);
boolean output = newMarkerVsBaseline(rr, response, InjectionPayloads.COMMAND_OUTPUT_MARKERS);
boolean error = newMarkerVsBaseline(rr, response, InjectionPayloads.COMMAND_ERROR_MARKERS);
return (output || error) ? "Critical" : null;
},
(base, evidence, payload, severity) -> buildIssue(base, evidence, ip, payload,
"API8:2023 - Security Misconfiguration (Command Injection)",
"Response indicates the OS attempted to execute the injected payload " +
"(either by leaking output or producing a shell error).",
"The payload introduced command-output or shell-error content that was " +
"NOT present in the baseline response — evidence the OS executed it.",
severity, "Firm", SQL_BACKGROUND));
}

private AuditIssue runXss(HttpRequestResponse rr, AuditInsertionPoint ip, Http http) {
return runFamily(rr, ip, http,
InjectionPayloads.XSS,
// Reflected markup is only an XSS vector if the response is
// actually rendered as HTML. A JSON API echoing the payload as
// a string value is not exploitable, so require an HTML
// Content-Type before firing.
(response, payload) -> {
if (!responseIsHtml(response)) return null;
String body = response.bodyToString();
return (body != null && body.contains(payload)) ? "Medium" : null;
},
(base, evidence, payload, severity) -> buildIssue(base, evidence, ip, payload,
"API8:2023 - Security Misconfiguration (Reflected XSS in API Response)",
"Response reflects the payload unencoded. If the response is ever " +
"rendered as HTML this is exploitable.",
"An HTML response reflects the payload unencoded — it executes in a " +
"browser context.",
severity, "Firm", SQL_BACKGROUND));
}

Expand Down Expand Up @@ -220,6 +231,37 @@ private static boolean bodyContainsAny(HttpResponse response, List<String> needl
return false;
}

/**
* True if a marker appears in {@code response} but NOT in the baseline
* (unmutated) response. This is what distinguishes a marker the payload
* actually produced (real injection evidence) from static content — API
* docs listing {@code /bin/bash}, an error page that always contains a
* SQL keyword, etc. Without it, marker presence alone over-fires.
*/
private static boolean newMarkerVsBaseline(HttpRequestResponse base,
HttpResponse response,
List<String> markersLower) {
String body = response.bodyToString();
if (body == null) return false;
String lower = body.toLowerCase(Locale.ROOT);
String baseBody = (base != null && base.hasResponse()) ? base.response().bodyToString() : null;
String baseLower = baseBody == null ? "" : baseBody.toLowerCase(Locale.ROOT);
for (String marker : markersLower) {
if (lower.contains(marker) && !baseLower.contains(marker)) return true;
}
return false;
}

private static boolean responseIsHtml(HttpResponse response) {
for (HttpHeader header : response.headers()) {
if ("content-type".equalsIgnoreCase(header.name())) {
String v = header.value();
return v != null && v.toLowerCase(Locale.ROOT).contains("html");
}
}
return false;
}

// ---- Issue construction ------------------------------------------------

private AuditIssue buildIssue(HttpRequestResponse base,
Expand Down
Loading
Loading