Skip to content

Commit 2d9e9ed

Browse files
committed
Implementing support for 429 (rate limit)
1 parent 075d009 commit 2d9e9ed

6 files changed

Lines changed: 94 additions & 4 deletions

File tree

extra/vulnserver/vulnserver.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,10 @@ def _xpath_element_to_dict(el):
407407
_server = None
408408
_alive = False
409409
_csrf_token = None
410+
_ratelimit_hits = 0
411+
412+
# number of initial hits to '/ratelimit' answered with 429 before it behaves normally
413+
RATELIMIT_INITIAL_429 = 1
410414

411415
def init(quiet=False):
412416
global _conn
@@ -963,6 +967,22 @@ def do_REQUEST(self):
963967
self.wfile.write(b"<html><body>Request blocked: security policy violation (WAF)</body></html>")
964968
return
965969

970+
# rate-limit emulator ('/ratelimit'): the first hit(s) answer 429 with a 'Retry-After', then
971+
# it behaves like the default SQLi endpoint - so a client that honors the backoff and retries
972+
# eventually gets through (drives the adaptive rate-limit handling)
973+
if self.url == "/ratelimit":
974+
global _ratelimit_hits
975+
_ratelimit_hits += 1
976+
if _ratelimit_hits <= RATELIMIT_INITIAL_429:
977+
self.send_response(429)
978+
self.send_header("Retry-After", "0")
979+
self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING)
980+
self.send_header("Connection", "close")
981+
self.end_headers()
982+
self.wfile.write(b"<html><body>Too Many Requests</body></html>")
983+
return
984+
self.url = "/"
985+
966986
if self.url == "/xxe":
967987
self.send_response(OK)
968988
self.send_header("Content-type", "application/xml; charset=%s" % UNICODE_ENCODING)

lib/core/enums.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ class HTTP_HEADER(object):
282282
RANGE = "Range"
283283
REFERER = "Referer"
284284
REFRESH = "Refresh" # Reference: http://stackoverflow.com/a/283794
285+
RETRY_AFTER = "Retry-After"
285286
SERVER = "Server"
286287
SET_COOKIE = "Set-Cookie"
287288
TRANSFER_ENCODING = "Transfer-Encoding"

lib/core/settings.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from thirdparty import six
2121

2222
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
23-
VERSION = "1.10.7.181"
23+
VERSION = "1.10.7.182"
2424
TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable"
2525
TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34}
2626
VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE)
@@ -58,6 +58,17 @@
5858
# false positive) rather than the back-end actually answering.
5959
WAF_BLOCK_HTTP_CODES = (403, 406, 429, 451, 501, 503)
6060

61+
# HTTP status signalling that the client is being rate-limited (kept as a literal because Python 2's
62+
# httplib has no such constant)
63+
TOO_MANY_REQUESTS_HTTP_CODE = 429
64+
65+
# Adaptive rate-limit handling: one-time backoff used when a rate-limited response carries no usable
66+
# 'Retry-After', the additive step by which the inter-request delay is raised on each hit, and the
67+
# ceiling for both the honored backoff and the auto-throttle (seconds)
68+
RATE_LIMIT_DEFAULT_DELAY = 1.0
69+
RATE_LIMIT_DELAY_STEP = 0.5
70+
RATE_LIMIT_MAX_DELAY = 60.0
71+
6172
# Candidate tamper scripts for automatic WAF-bypass, ordered by empirical WAF-bypass value
6273
# (structural token-substitution first, camouflage last; per identYwaf data). The back-end DBMS
6374
# is not pre-filtered here: semantics-preservation is verified at runtime by re-running detection

lib/core/testing.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def vulnTest(tests=None, label="vuln"):
6767
("-u <url> --data=\"security_level=5\" -p id --flush-session --technique=B", ("unable to automatically bypass the WAF/IPS", "does not seem to be injectable")), # automatic WAF-bypass honest bail: a libinjection-class WAF that no User-Agent or tamper can defeat
6868
("-u <url> -p id --flush-session --technique=B --proof", ("sqlmap proved exploitation of the following injection point", "Parameter: id (GET)", "Technique: boolean-based blind", "TRUE (5/5)", "repeatably", "Retrieved: back-end DBMS banner '3.")), # --proof: report-grade proof in the injection-point style - forces the boolean technique (so a multi-technique point still proves), and actively reads a value out as the strongest proof
6969
("-u <base> --mine-params --flush-session --technique=B", ("mining for hidden GET parameters", "found hidden parameter 'id'", "held back parameter(s) that break the base request", "Parameter: id (GET)", "Type: boolean-based blind")), # --mine-params: discover an injectable parameter absent from a bare URL, hold back the raw-SQL sink that would shadow it, then confirm the injection on the mined 'id'
70+
("-u \"<base>ratelimit?id=1\" --flush-session --technique=B", ("target appears to be rate-limiting", "Parameter: id (GET)", "Type: boolean-based blind")), # adaptive rate-limit handling: the endpoint answers 429 with 'Retry-After' first, so detection only succeeds if sqlmap honors the backoff, throttles, and retries rather than treating 429 as a hard block
7071
("-r <request> --flush-session -v 5 --test-skip=\"heavy\" --save=<config>", ("CloudFlare", "web application technology: Express", "possible DBMS: 'SQLite'", "User-Agent: foobar", "~Type: time-based blind", "saved command line options to the configuration file")),
7172
("-c <config>", ("CloudFlare", "possible DBMS: 'SQLite'", "User-Agent: foobar", "~Type: time-based blind")),
7273
("-l <log> --flush-session --skip-waf -vvvvv --technique=U --union-from=users --banner --parse-errors", ("banner: '3.", "ORDER BY term out of range", "~xp_cmdshell", "Connection: keep-alive")),

lib/request/connect.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
"""
77

88
import binascii
9+
import calendar
10+
import email.utils
911
import inspect
1012
import io
1113
import logging
@@ -119,9 +121,13 @@
119121
from lib.core.settings import PLAIN_TEXT_CONTENT_TYPE
120122
from lib.core.settings import RANDOM_INTEGER_MARKER
121123
from lib.core.settings import RANDOM_STRING_MARKER
124+
from lib.core.settings import RATE_LIMIT_DEFAULT_DELAY
125+
from lib.core.settings import RATE_LIMIT_DELAY_STEP
126+
from lib.core.settings import RATE_LIMIT_MAX_DELAY
122127
from lib.core.settings import REPLACEMENT_MARKER
123128
from lib.core.settings import SAFE_HEX_MARKER
124129
from lib.core.settings import TEXT_CONTENT_TYPE_REGEX
130+
from lib.core.settings import TOO_MANY_REQUESTS_HTTP_CODE
125131
from lib.core.settings import UNENCODED_ORIGINAL_VALUE
126132
from lib.core.settings import UNICODE_ENCODING
127133
from lib.core.settings import URI_HTTP_HEADER
@@ -223,6 +229,49 @@ def _retryProxy(**kwargs):
223229
kwargs['retrying'] = True
224230
return Connect._getPageProxy(**kwargs)
225231

232+
@staticmethod
233+
def _parseRetryAfter(responseHeaders):
234+
"""
235+
Parses a 'Retry-After' response header (RFC 7231 delta-seconds or an HTTP-date) into a number
236+
of seconds to wait, or None when it is absent or unparseable.
237+
"""
238+
239+
value = (responseHeaders.get(HTTP_HEADER.RETRY_AFTER) if responseHeaders else None) or ""
240+
value = value.strip()
241+
242+
if value.isdigit():
243+
return float(value)
244+
245+
parsed = email.utils.parsedate(value)
246+
return max(0.0, calendar.timegm(parsed) - time.time()) if parsed else None
247+
248+
@staticmethod
249+
def _rateLimitRetry(responseHeaders, code, **kwargs):
250+
"""
251+
Handles a rate-limited response by honoring its 'Retry-After' (capped), adaptively raising the
252+
inter-request delay so subsequent requests self-throttle under the limit, then re-issuing the
253+
request. Returns the retried (page, headers, code) or None when the retry budget is exhausted,
254+
so the caller can surface the rate-limited response as-is.
255+
"""
256+
257+
threadData = getCurrentThreadData()
258+
if threadData.retriesCount >= conf.retries or kb.threadException:
259+
return None
260+
261+
retryAfter = Connect._parseRetryAfter(responseHeaders)
262+
backoff = min(retryAfter if retryAfter is not None else RATE_LIMIT_DEFAULT_DELAY, RATE_LIMIT_MAX_DELAY)
263+
264+
# additive-increase throttle: nudge the inter-request delay up toward a sustainable pace. The
265+
# auto-throttle is capped, but a larger user-set '--delay' is never lowered. It is monotonic,
266+
# so a lost concurrent update across threads self-heals on the next hit.
267+
conf.delay = max(conf.delay or 0, min(RATE_LIMIT_MAX_DELAY, (conf.delay or 0) + RATE_LIMIT_DELAY_STEP))
268+
269+
singleTimeWarnMessage("target appears to be rate-limiting requests; sqlmap is backing off and throttling accordingly (consider raising '--delay' or lowering '--threads')")
270+
logger.debug("rate-limited (HTTP %d)%s; sleeping %.1f second(s), inter-request delay now %.1f second(s)" % (code, " honoring 'Retry-After'" if retryAfter is not None else "", backoff, conf.delay))
271+
272+
time.sleep(backoff)
273+
return Connect._retryProxy(**kwargs)
274+
226275
@staticmethod
227276
def _connReadProxy(conn):
228277
parts = []
@@ -844,7 +893,13 @@ class _(dict):
844893
raise SystemExit
845894

846895
if ex.code not in (conf.ignoreCode or []):
847-
if ex.code == _http_client.UNAUTHORIZED:
896+
if ex.code == TOO_MANY_REQUESTS_HTTP_CODE or (ex.code == _http_client.SERVICE_UNAVAILABLE and Connect._parseRetryAfter(responseHeaders) is not None):
897+
retried = Connect._rateLimitRetry(responseHeaders, ex.code, **kwargs)
898+
if retried is not None:
899+
return retried
900+
debugMsg = "target kept rate-limiting after %d retries (%d)" % (conf.retries, code)
901+
logger.debug(debugMsg)
902+
elif ex.code == _http_client.UNAUTHORIZED:
848903
errMsg = "not authorized, try to provide right HTTP "
849904
errMsg += "authentication type and valid credentials (%d). " % code
850905
errMsg += "If this is intended, try to rerun by providing "

tests/test_brute.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,10 @@ def test_column_exists_collects_and_types(self):
161161

162162
def _cbe(expression, expectingNone=True):
163163
calls["n"] += 1
164-
# initial sanity probe uses two random strings (no real column name)
165-
if "id" not in expression and "name" not in expression:
164+
# initial sanity probe queries a random table, not the real 'users' one - so keying on the
165+
# table name is collision-proof (unlike a column-name substring, which a random probe value
166+
# can incidentally contain, e.g. 'id')
167+
if "users" not in expression:
166168
return False
167169
# MySQL numeric-type follow-up: `not checkBooleanExpression(... REGEXP '[^0-9]')`.
168170
# 'id' is numeric (no non-digit chars => probe False => numeric);

0 commit comments

Comments
 (0)