From 38d586fb83fc594c300c0aa60f3ffe61da38bcf4 Mon Sep 17 00:00:00 2001 From: liquidsec Date: Mon, 3 Aug 2026 18:58:37 -0400 Subject: [PATCH] fix carve misses on __COMPRESSED_VSTATE fields - add __COMPRESSED_VSTATE to the compressed viewstate carve regex and YARA prefilter; it was in neither, so pages using that field name were never detected - _carve_body walks re.finditer instead of taking only re.search's first match, so an empty decoy __VIEWSTATE no longer shadows a real payload - reshape the aspnet_compressedviewstate and jsf_viewstate carve regexes to assert the field name in a lookahead: allows attributes between name= and value=, and fixes quadratic backtracking --- badsecrets/base.py | 34 +++++---- .../passive/aspnet_compressedviewstate.py | 8 ++- badsecrets/modules/passive/jsf_viewstate.py | 2 +- tests/aspnet_compressedviewstate_test.py | 71 +++++++++++++++++++ 4 files changed, 97 insertions(+), 18 deletions(-) diff --git a/badsecrets/base.py b/badsecrets/base.py index f5a5e7e..5c9ff7b 100644 --- a/badsecrets/base.py +++ b/badsecrets/base.py @@ -190,21 +190,25 @@ def _carve_body(self, body, cookies, headers, **kwargs): """Extract secrets from HTML body text. Override in subclasses for custom body carving.""" results = [] if self.carve_regex(): - s = re.search(self.carve_regex(), body) - if s: - if not self.validate_carve or self.identify(s.groups()[0]): - r = self.carve_to_check_secret( - s, url=kwargs.get("url"), body=body, cookies=cookies, headers=headers - ) - if r: - r["type"] = "SecretFound" - else: - r = {"type": "IdentifyOnly"} - r["hashcat"] = self._safe_hashcat(s.groups()[0]) - if "product" not in r: - r["product"] = self.get_product_from_carve(s) - r["location"] = "body" - results.append(r) + # Walk every match rather than only the first. A page can carry several fields the + # carve regex matches -- an empty or decoy __VIEWSTATE alongside a real payload -- + # and stopping at match #1 lets whichever appears first in the markup hide the rest. + for s in re.finditer(self.carve_regex(), body): + if self.validate_carve and not self.identify(s.groups()[0]): + continue + r = self.carve_to_check_secret(s, url=kwargs.get("url"), body=body, cookies=cookies, headers=headers) + if r: + r["type"] = "SecretFound" + else: + r = {"type": "IdentifyOnly"} + r["hashcat"] = self._safe_hashcat(s.groups()[0]) + if "product" not in r: + r["product"] = self.get_product_from_carve(s) + r["location"] = "body" + results.append(r) + # First candidate that identifies wins, keeping the one-result-per-body + # contract and avoiding repeat check_secret() work on expensive modules. + break return results @classmethod diff --git a/badsecrets/modules/passive/aspnet_compressedviewstate.py b/badsecrets/modules/passive/aspnet_compressedviewstate.py index 2207403..fc47e2d 100644 --- a/badsecrets/modules/passive/aspnet_compressedviewstate.py +++ b/badsecrets/modules/passive/aspnet_compressedviewstate.py @@ -12,13 +12,17 @@ class ASPNET_compressedviewstate(BadsecretsBase): yara_carve_rule = ( "rule ASPNET_compressedviewstate_carve {" ' strings: $vs = "__VIEWSTATE" $vstate = "__VSTATE" $cvs = "__COMPRESSEDVIEWSTATE"' - " condition: $vs or $vstate or $cvs }" + ' $cvs_u = "__COMPRESSED_VSTATE"' + " condition: $vs or $vstate or $cvs or $cvs_u }" ) description = {"product": "ASP.NET Compressed Viewstate", "secret": "unprotected", "severity": "CRITICAL"} carve_locations = ("body",) def carve_regex(self): - return re.compile(r"]+__(?:VIEWSTATE|VSTATE|COMPRESSEDVIEWSTATE)\"\s*value=\"(.*?)\"") + return re.compile( + r"]*__(?:COMPRESSEDVIEWSTATE|COMPRESSED_VSTATE|VIEWSTATE|VSTATE)\")" + r"[^>]*?\svalue=\"(.*?)\"" + ) def check_secret(self, compressed_viewstate): if not self.identify(compressed_viewstate): diff --git a/badsecrets/modules/passive/jsf_viewstate.py b/badsecrets/modules/passive/jsf_viewstate.py index 0083c27..4346690 100644 --- a/badsecrets/modules/passive/jsf_viewstate.py +++ b/badsecrets/modules/passive/jsf_viewstate.py @@ -28,7 +28,7 @@ class Jsf_viewstate(BadsecretsBase): carve_locations = ("body",) def carve_regex(self): - return re.compile(r"]*name=\"javax\.faces\.ViewState\")[^>]*?\svalue=\"([^\"]*)\"") # Mojarra 1.2.x - 2.0.3 def DES3_decrypt(self, ct, password): diff --git a/tests/aspnet_compressedviewstate_test.py b/tests/aspnet_compressedviewstate_test.py index 68f5d6a..6fd90b8 100644 --- a/tests/aspnet_compressedviewstate_test.py +++ b/tests/aspnet_compressedviewstate_test.py @@ -1,4 +1,5 @@ import gzip +import time import base64 from badsecrets import modules_loaded @@ -89,6 +90,76 @@ def test_aspnet_compressedviewstate_carve_compressedviewstate_field(): assert results[0]["type"] == "SecretFound" +def test_aspnet_compressedviewstate_carve_compressed_vstate_field(): + """Carve from the __COMPRESSED_VSTATE (underscore) hidden field.""" + body = f'' + x = ASPNETcompressedviewstate() + results = x.carve(body=body) + assert len(results) > 0 + assert results[0]["type"] == "SecretFound" + assert results[0]["location"] == "body" + + +def test_aspnet_compressedviewstate_carve_not_shadowed_by_empty_viewstate(): + """An empty __VIEWSTATE field must not hide a real payload, in either document order.""" + payload = f'' + empty = '' + x = ASPNETcompressedviewstate() + for body in (payload + empty, empty + payload): + results = x.carve(body=body) + secret_results = [r for r in results if r["type"] == "SecretFound"] + assert len(secret_results) == 1, f"missed payload for body order: {body[:60]}" + assert secret_results[0]["product"] == KNOWN_GOOD + + +def test_aspnet_compressedviewstate_carve_all_modules_compressed_vstate(): + """__COMPRESSED_VSTATE next to an empty __VIEWSTATE, as seen in the wild.""" + body = ( + '
\n' + f'\n' + '\n' + "
" + ) + results = carve_all_modules(body=body) + found = [r for r in results if r["detecting_module"] == "ASPNET_compressedviewstate"] + assert len(found) == 1 + assert found[0]["type"] == "SecretFound" + assert found[0]["description"]["severity"] == "CRITICAL" + + +def test_aspnet_compressedviewstate_carve_attribute_between_name_and_value(): + """Unrelated attributes between name= and value= must not defeat the carve.""" + body = f'' + x = ASPNETcompressedviewstate() + results = x.carve(body=body) + assert len(results) > 0 + assert results[0]["type"] == "SecretFound" + + +def test_aspnet_compressedviewstate_carve_does_not_cross_tag_boundary(): + """A name in one tag must not pair with a value in the next.""" + body = '' + x = ASPNETcompressedviewstate() + assert x.carve(body=body) == [] + + +def test_aspnet_compressedviewstate_carve_regex_no_catastrophic_backtracking(): + """Many name hits in one unclosed tag must stay linear, not O(n^2).""" + body = "' + x = ASPNETcompressedviewstate() + assert x.carve(body=body) == [] + + def test_aspnet_compressedviewstate_carve_bad_value(): """Carve with a non-compressed value in the field should not return SecretFound.""" body = ''