Skip to content

Commit 3148e7f

Browse files
authored
Merge pull request #2910 from blacklanternsecurity/lightfuzz-bugfix
Fix lightfuzz envelope cross-contamination between submodules
2 parents 5d76e79 + 06d806a commit 3148e7f

4 files changed

Lines changed: 136 additions & 3 deletions

File tree

bbot/core/helpers/web/envelopes.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,36 @@ def get_subparam(self, key=None, recursive=True):
166166
data = data[segment]
167167
return data
168168

169+
def pack_value(self, value, key=None):
170+
"""
171+
Pack a value through the envelope chain WITHOUT modifying internal state.
172+
"""
173+
if key is None:
174+
key = self.selected_subparam
175+
176+
inner = self.unpacked_data(recursive=False)
177+
178+
if hasattr(inner, "pack_value"):
179+
# Inner is another envelope - delegate down the chain
180+
data = inner.pack_value(value, key)
181+
elif self.singleton:
182+
# At the leaf singleton - use the new value directly
183+
data = value
184+
else:
185+
# At the leaf non-singleton (JSON/XML) - copy the data and substitute
186+
import copy
187+
188+
if key is None:
189+
raise ValueError("No subparam selected for non-singleton envelope")
190+
data = copy.deepcopy(inner)
191+
# In the loop: Traverse all the way down to the parent of the target value (all segments except the last),
192+
target = data
193+
for segment in key[:-1]:
194+
target = target[segment]
195+
# Use the final segment to actually assign the value.
196+
target[key[-1]] = value
197+
return self._pack(data)
198+
169199
def set_subparam(self, key=None, value=None, recursive=True):
170200
envelope = self
171201
if recursive:

bbot/modules/lightfuzz/submodules/base.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -265,13 +265,16 @@ def incoming_probe_value(self, populate_empty=True):
265265

266266
def outgoing_probe_value(self, outgoing_probe_value):
267267
"""
268-
Transparently modifies the outgoing probe value (fuzz probe being sent to the target), given any envelopes that may have been identified, so that fuzzing within the envelopes can occur.
268+
Transparently packs the outgoing probe value (fuzz probe being sent to the target) through
269+
any envelopes that may have been identified, so that fuzzing within the envelopes can occur.
270+
271+
Uses pack_value() to avoid mutating the envelope's internal state, preventing cross-contamination
272+
between submodules that share the same event/envelope object.
269273
"""
270274
self.debug(f"outgoing_probe_value (before packing): {outgoing_probe_value} / {self.event}")
271275
envelopes = getattr(self.event, "envelopes", None)
272276
if envelopes is not None:
273-
envelopes.set_subparam(value=outgoing_probe_value)
274-
outgoing_probe_value = envelopes.pack()
277+
outgoing_probe_value = envelopes.pack_value(outgoing_probe_value)
275278
self.debug(
276279
f"outgoing_probe_value (after packing): {outgoing_probe_value} with envelopes [{envelopes}] / {self.event}"
277280
)

bbot/test/test_step_1/test_web_envelopes.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,3 +341,77 @@ async def test_web_envelopes():
341341

342342
tiny_base64 = BaseEnvelope.detect("YWJi")
343343
assert isinstance(tiny_base64, TextEnvelope)
344+
345+
346+
async def test_web_envelope_pack_value():
347+
"""
348+
Test pack_value() - encodes a value through the envelope chain without modifying internal state.
349+
"""
350+
import base64
351+
import json
352+
353+
from bbot.core.helpers.web.envelopes import BaseEnvelope
354+
355+
# Text envelope (singleton, transparent)
356+
text_envelope = BaseEnvelope.detect("original_text")
357+
assert text_envelope.pack_value("new_text") == "new_text"
358+
assert text_envelope.get_subparam() == "original_text"
359+
360+
# Hex envelope (singleton chain: hex -> text)
361+
hex_envelope = BaseEnvelope.detect("706172616d") # "param" in hex
362+
packed = hex_envelope.pack_value("modified")
363+
assert packed == "modified".encode().hex()
364+
assert hex_envelope.get_subparam() == "param"
365+
366+
# Base64 envelope (singleton chain: base64 -> text)
367+
b64_envelope = BaseEnvelope.detect("cGFyYW0=") # "param" in base64
368+
packed = b64_envelope.pack_value("modified")
369+
assert packed == base64.b64encode(b"modified").decode()
370+
assert b64_envelope.get_subparam() == "param"
371+
372+
# Nested hex -> base64 -> text chain
373+
nested_envelope = BaseEnvelope.detect("634746795957303d") # hex(base64("param"))
374+
packed = nested_envelope.pack_value("modified")
375+
expected = base64.b64encode(b"modified").decode().encode().hex()
376+
assert packed == expected
377+
assert nested_envelope.get_subparam() == "param"
378+
379+
# URL envelope (singleton chain: url -> text)
380+
url_envelope = BaseEnvelope.detect("a%20b%20c")
381+
packed = url_envelope.pack_value("x y z")
382+
assert packed == "x%20y%20z"
383+
assert url_envelope.get_subparam() == "a b c"
384+
385+
# JSON inside base64 (non-singleton: base64 -> json) - only the selected subparam is substituted in the output
386+
b64_json = BaseEnvelope.detect("eyJwYXJhbTEiOiAidmFsMSIsICJwYXJhbTIiOiB7InBhcmFtMyI6ICJ2YWwzIn19")
387+
b64_json.selected_subparam = ["param2", "param3"]
388+
packed = b64_json.pack_value("new_val3")
389+
decoded_json = json.loads(base64.b64decode(packed).decode())
390+
assert decoded_json["param1"] == "val1"
391+
assert decoded_json["param2"]["param3"] == "new_val3"
392+
assert b64_json.get_subparam() == "val3"
393+
assert b64_json.get_subparam(["param1"]) == "val1"
394+
395+
# Repeated calls do not accumulate - each starts from the original state
396+
hex_envelope = BaseEnvelope.detect("706172616d")
397+
hex_envelope.pack_value("first_modification")
398+
hex_envelope.pack_value("second_modification")
399+
hex_envelope.pack_value("third_modification")
400+
assert hex_envelope.get_subparam() == "param"
401+
402+
# Multiple callers sharing the same envelope each produce correct output independently
403+
shared_envelope = BaseEnvelope.detect("706172616d") # "param" in hex
404+
405+
probe_a = shared_envelope.pack_value("param' OR 1=1--")
406+
assert probe_a == "param' OR 1=1--".encode().hex()
407+
assert shared_envelope.get_subparam() == "param"
408+
409+
probe_b = shared_envelope.pack_value("param| echo 1234 |")
410+
assert probe_b == "param| echo 1234 |".encode().hex()
411+
assert shared_envelope.get_subparam() == "param"
412+
413+
probe_c = shared_envelope.pack_value("../../etc/passwd")
414+
assert probe_c == "../../etc/passwd".encode().hex()
415+
416+
assert shared_envelope.get_subparam() == "param"
417+
assert shared_envelope.pack() == "706172616d"

bbot/test/test_step_2/module_tests/test_module_lightfuzz.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1885,3 +1885,29 @@ def check(self, module_test, events):
18851885

18861886
assert web_parameter_emitted, "WEB_PARAMETER was not emitted"
18871887
assert esi_finding_emitted, "ESI FINDING not emitted"
1888+
1889+
1890+
# Envelope state isolation: crypto error detection with all submodules enabled.
1891+
# Crypto runs after sqli/cmdi/xss/path/ssti. Each prior submodule calls outgoing_probe_value()
1892+
# which must not corrupt the envelope state that crypto reads via incoming_probe_value().
1893+
class Test_Lightfuzz_envelope_isolation_crypto(Test_Lightfuzz_crypto_error):
1894+
config_overrides = {
1895+
"interactsh_disable": True,
1896+
"modules": {
1897+
"lightfuzz": {
1898+
"enabled_submodules": ["sqli", "cmdi", "xss", "path", "ssti", "crypto", "serial", "esi"],
1899+
}
1900+
},
1901+
}
1902+
1903+
1904+
# Envelope state isolation: padding oracle detection with all submodules enabled.
1905+
class Test_Lightfuzz_envelope_isolation_paddingoracle(Test_Lightfuzz_PaddingOracleDetection):
1906+
config_overrides = {
1907+
"interactsh_disable": True,
1908+
"modules": {
1909+
"lightfuzz": {
1910+
"enabled_submodules": ["sqli", "cmdi", "xss", "path", "ssti", "crypto", "serial", "esi"],
1911+
}
1912+
},
1913+
}

0 commit comments

Comments
 (0)