1+ from urllib .parse import quote
2+
13from .base import BaseLightfuzz
24from bbot .errors import HttpCompareError
5+ from bbot .core .helpers .diff import parse_body
36
47
58class sqli (BaseLightfuzz ):
@@ -13,6 +16,10 @@ class sqli(BaseLightfuzz):
1316 - Tests quote escape sequence variations
1417 - Matches against known SQL error patterns
1518
19+ * Code-change Detection:
20+ - Compares the status code of a single-quote probe against a doubled-quote probe
21+ - Requires a positive boolean (TRUE/FALSE) content differential to confirm
22+
1623 * Time-based Blind Detection:
1724 - Uses vendor-specific time delay payloads
1825 - Confirms delays with statistical analysis
@@ -41,6 +48,14 @@ class sqli(BaseLightfuzz):
4148 "string not properly terminated" ,
4249 ]
4350
51+ # TRUE/FALSE payload pairs used to confirm the value reaches a SQL query. Both halves of a
52+ # pair are the same length and differ by a single character, so a reflected payload can be
53+ # stripped cleanly and any surviving body difference comes from the query result set.
54+ BOOLEAN_PROBE_PAIRS = [
55+ ("' AND '1'='1" , "' AND '1'='2" ),
56+ (" AND 1=1" , " AND 1=2" ),
57+ ]
58+
4459 DELAY_PROBE_TEMPLATES = [
4560 "'||pg_sleep({d})--" ,
4661 "' OR (SELECT TRUE FROM pg_sleep({d})) LIMIT 1-- -" ,
@@ -115,6 +130,106 @@ async def _confirm_code_change(self, probe_value, cookies, initial_status_codes,
115130
116131 return True
117132
133+ @staticmethod
134+ def _strip_payload (text , payload ):
135+ """Remove reflected copies of a payload from a response body, in raw and encoded form."""
136+ for variant in (payload , quote (payload ), payload .replace (" " , "+" )):
137+ text = text .replace (variant , "" )
138+ return text
139+
140+ async def _probe_body (self , http_compare , payload , cookies ):
141+ """Send ``payload`` and return its parsed response body, reflections of the payload
142+ stripped, ready for ``http_compare.compare_body()``.
143+
144+ Returns None when the probe fails, or when the response is 403/429 (WAF or rate limit,
145+ which tells us nothing about the query behind the parameter).
146+ """
147+ try :
148+ probe = await self .compare_probe (
149+ http_compare ,
150+ self .event .data ["type" ],
151+ payload ,
152+ cookies ,
153+ additional_params_populate_empty = True ,
154+ )
155+ except HttpCompareError as e :
156+ self .debug (f"Boolean probe [{ payload } ] failed: { e } " )
157+ return None
158+ if not probe [3 ]:
159+ return None
160+ if probe [3 ].status_code in (403 , 429 ):
161+ self .debug (f"Boolean probe [{ payload } ] returned { probe [3 ].status_code } , cannot confirm" )
162+ return None
163+ return parse_body (self ._strip_payload (probe [3 ].text , payload ))
164+
165+ async def confirm_boolean_differential (self , http_compare , probe_value , cookies ):
166+ """Require positive SQL-logic evidence before asserting injection from a status change.
167+
168+ A bare status flip is not SQL: a WAF signature match, or an envelope whose structural
169+ validity changes when the payload is repacked, both produce one. Only a TRUE/FALSE pair
170+ that changes the response *content* shows the value is reaching a query.
171+
172+ Returns the confirming ``(true_payload, false_payload)`` pair, or None.
173+ """
174+ for true_suffix , false_suffix in self .BOOLEAN_PROBE_PAIRS :
175+ true_payload = f"{ probe_value } { true_suffix } "
176+ false_payload = f"{ probe_value } { false_suffix } "
177+
178+ true_body = await self ._probe_body (http_compare , true_payload , cookies )
179+ if true_body is None :
180+ continue
181+ false_body = await self ._probe_body (http_compare , false_payload , cookies )
182+ if false_body is None :
183+ continue
184+
185+ if http_compare .compare_body (true_body , false_body ) is not False :
186+ self .debug (f"No boolean differential for [{ true_suffix } ] / [{ false_suffix } ]" )
187+ continue
188+
189+ # A page that renders differently on every request produces a differential on its
190+ # own. Re-send the TRUE payload; the body must reproduce for the pair to mean anything.
191+ repeat_body = await self ._probe_body (http_compare , true_payload , cookies )
192+ if repeat_body is None :
193+ continue
194+ if http_compare .compare_body (true_body , repeat_body ) is False :
195+ self .debug ("Response body is not deterministic, discarding boolean differential" )
196+ continue
197+
198+ self .verbose (f"Boolean differential confirmed for { self .event .url } : [{ true_suffix } ] vs [{ false_suffix } ]" )
199+ return true_payload , false_payload
200+ return None
201+
202+ async def is_quote_specific (self , http_compare , probe_value , cookies , status_codes ):
203+ """Verify the status flip tracks the quote characters and not the payload's shape.
204+
205+ Appending one vs. two benign characters mirrors the `'`/`''` pair in length while
206+ carrying no SQL meaning. If that benign pair reproduces the same status triplet, the
207+ flip tracks value length or envelope validity rather than quoting.
208+ """
209+ control_codes = []
210+ for suffix in ("a" , "aa" ):
211+ try :
212+ control = await self .compare_probe (
213+ http_compare ,
214+ self .event .data ["type" ],
215+ f"{ probe_value } { suffix } " ,
216+ cookies ,
217+ additional_params_populate_empty = True ,
218+ )
219+ except HttpCompareError as e :
220+ self .debug (f"Quote-specificity control probe failed: { e } " )
221+ return True
222+ if not control [3 ]:
223+ return True
224+ control_codes .append (control [3 ].status_code )
225+
226+ if (status_codes [0 ], * control_codes ) == status_codes :
227+ self .debug (
228+ f"Benign control pair reproduced the status triplet { status_codes } , the change is not quote-specific"
229+ )
230+ return False
231+ return True
232+
118233 async def fuzz (self ):
119234 cookies = self .event .data .get ("assigned_cookies" , {})
120235 probe_value = self .incoming_probe_value (populate_empty = True )
@@ -165,19 +280,14 @@ async def fuzz(self):
165280 if "code" in single_quote [1 ] and (
166281 single_quote [3 ].status_code != double_single_quote [3 ].status_code
167282 ):
168- # Check if the status code change is due to a WAF, not SQL injection
169- is_waf = False
170- if single_quote [3 ].status_code == 403 :
171- waf_matches = await self .lightfuzz .helpers .yara .match (
172- self .lightfuzz .waf_yara_rules , single_quote [3 ].text
283+ # A transition into 403 is access control (usually a WAF matching its managed
284+ # SQLi signature on the bare quote), not a code change driven by the query.
285+ if 403 in (single_quote [3 ].status_code , double_single_quote [3 ].status_code ):
286+ self .debug (
287+ "Quote probe transitioned into 403 (access control/WAF), "
288+ "suppressing SQL injection finding"
173289 )
174- if waf_matches :
175- self .debug (
176- "Single quote probe returned 403 with WAF signature, "
177- "suppressing SQL injection finding"
178- )
179- is_waf = True
180- if not is_waf :
290+ else :
181291 # Confirmation loop: require 2 additional rounds with fresh baselines
182292 # to confirm the status-code triplet is stable and not a transient CDN/server flap.
183293 # TODO: apply this same confirmation pattern to other submodules that use compare_probe-based detection.
@@ -187,15 +297,28 @@ async def fuzz(self):
187297 double_single_quote [3 ].status_code ,
188298 )
189299 confirmed = await self ._confirm_code_change (probe_value , cookies , initial_status_codes )
190- if confirmed :
300+ quote_specific = confirmed and await self .is_quote_specific (
301+ http_compare , probe_value , cookies , initial_status_codes
302+ )
303+ boolean_pair = (
304+ await self .confirm_boolean_differential (http_compare , probe_value , cookies )
305+ if quote_specific
306+ else None
307+ )
308+ if boolean_pair :
191309 self .results .append (
192310 {
193311 "name" : "Possible SQL Injection" ,
194312 "severity" : "HIGH" ,
195313 "confidence" : "MEDIUM" ,
196- "description" : f"Possible SQL Injection. { self .metadata ()} Detection Method: [Single Quote/Two Single Quote, Code Change ({ initial_status_codes [0 ]} ->{ initial_status_codes [1 ]} ->{ initial_status_codes [2 ]} )]" ,
314+ "description" : f"Possible SQL Injection. { self .metadata ()} Detection Method: [Single Quote/Two Single Quote, Code Change ({ initial_status_codes [0 ]} ->{ initial_status_codes [1 ]} ->{ initial_status_codes [2 ]} )] Boolean Confirmation: [ { boolean_pair [ 0 ] } ] vs [ { boolean_pair [ 1 ] } ] " ,
197315 }
198316 )
317+ elif confirmed and quote_specific :
318+ self .verbose (
319+ f"Discarding code change { initial_status_codes } for { self .event .url } : "
320+ "no boolean differential, the value does not reach a query"
321+ )
199322 else :
200323 self .debug ("Failed to get responses for both single_quote and double_single_quote" )
201324 except HttpCompareError as e :
0 commit comments