From 93597520063ee018ff3b99c2aacacb4f79967039 Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:26:04 +0800 Subject: [PATCH 1/2] FIX: raise on characters AsciiSmugglerConverter cannot encode encode_message collected characters outside the ASCII printable range (0x20-0x7E), logged them at error level, and dropped them, while convert_async still returned a success ConverterResult. A non-ASCII objective was therefore smuggled as a corrupted or empty payload and the run reported success. Raise ValueError naming the offending characters and pointing to the lossless sibling converters instead. Adds regression tests (pre-fix: DID NOT RAISE) covering the reported non-ASCII cases, an astral-plane emoji, a control character, and the inclusive 0x20/0x7E boundaries. --- .../ascii_smuggler_converter.py | 27 ++++++++++++------- .../test_ascii_smuggler_converter.py | 21 +++++++++++++++ 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/pyrit/converter/token_smuggling/ascii_smuggler_converter.py b/pyrit/converter/token_smuggling/ascii_smuggler_converter.py index c91059d3ea..be18271662 100644 --- a/pyrit/converter/token_smuggling/ascii_smuggler_converter.py +++ b/pyrit/converter/token_smuggling/ascii_smuggler_converter.py @@ -59,26 +59,33 @@ def encode_message(self, *, message: str) -> tuple[str, str]: Returns: tuple[str, str]: A tuple with a summary of code points and the encoded message. + + Raises: + ValueError: If ``message`` contains a character outside the ASCII printable range (0x20-0x7E), + which has no Unicode Tag representation. Such characters were previously dropped while the + result still reported success; use ``SneakyBitsSmugglerConverter`` or + ``VariationSelectorSmugglerConverter`` to smuggle arbitrary Unicode losslessly. """ + invalid_chars = "".join(char for char in message if not 0x20 <= ord(char) <= 0x7E) + if invalid_chars: + raise ValueError( + f"Cannot encode {len(invalid_chars)} character(s) outside the ASCII printable range " + f"(0x20-0x7E): {invalid_chars!r}. Use SneakyBitsSmugglerConverter or " + f"VariationSelectorSmugglerConverter for lossless Unicode smuggling." + ) + encoded = "" code_points = "" - invalid_chars = "" if self.unicode_tags: encoded += chr(0xE0001) code_points += "U+E0001 " for char in message: - if 0x20 <= ord(char) <= 0x7E: - code_point = 0xE0000 + ord(char) - encoded += chr(code_point) - code_points += f"U+{code_point:X} " - else: - invalid_chars += char + code_point = 0xE0000 + ord(char) + encoded += chr(code_point) + code_points += f"U+{code_point:X} " if self.unicode_tags: encoded += chr(0xE007F) code_points += "U+E007F" - - if invalid_chars: - logger.error(f"Invalid characters detected: {invalid_chars}") return code_points, encoded def decode_message(self, *, message: str) -> str: diff --git a/tests/unit/converter/test_ascii_smuggler_converter.py b/tests/unit/converter/test_ascii_smuggler_converter.py index f5fe442862..30d48fcedb 100644 --- a/tests/unit/converter/test_ascii_smuggler_converter.py +++ b/tests/unit/converter/test_ascii_smuggler_converter.py @@ -46,3 +46,24 @@ async def test_ascii_smuggler_input_not_supported(): converter = AsciiSmugglerConverter(action="encode") with pytest.raises(ValueError, match="Input type not supported"): await converter.convert_async(prompt="test", input_type="image_path") + + +@pytest.mark.parametrize( + "prompt", ["caf\u00e9", "\u4f60\u597d\u4e16\u754c", "na\u00efve r\u00e9sum\u00e9", "\U0001f600", "line1\nline2"] +) +async def test_ascii_smuggler_encode_unrepresentable_chars_raise(prompt: str): + # Only the ASCII printable range (0x20-0x7E) maps to Unicode tags. Characters outside it used to be + # logged and dropped while convert_async still returned a success result, so a non-ASCII (or multi-line) + # objective was smuggled as a corrupted payload and an attack could be scored against a target that was + # never sent the objective. Encoding must fail loudly instead. + converter = AsciiSmugglerConverter(action="encode") + with pytest.raises(ValueError, match="ASCII printable range"): + await converter.convert_async(prompt=prompt, input_type="text") + + +async def test_ascii_smuggler_encode_printable_boundaries_do_not_raise(): + # 0x20 (space) and 0x7E (~) are the inclusive edges of the representable range and must still encode. + converter = AsciiSmugglerConverter(action="encode") + result = await converter.convert_async(prompt=" ~", input_type="text") + assert isinstance(result, ConverterResult) + assert result.output_type == "text" From 18a90a72d41013f1d22e3cd4a82d933d1dbe095f Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Tue, 8 Sep 2026 09:22:30 -0700 Subject: [PATCH 2/2] TEST: cover ASCII smuggler range boundaries Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../converter/test_ascii_smuggler_converter.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/unit/converter/test_ascii_smuggler_converter.py b/tests/unit/converter/test_ascii_smuggler_converter.py index 30d48fcedb..f0ac12069a 100644 --- a/tests/unit/converter/test_ascii_smuggler_converter.py +++ b/tests/unit/converter/test_ascii_smuggler_converter.py @@ -49,9 +49,18 @@ async def test_ascii_smuggler_input_not_supported(): @pytest.mark.parametrize( - "prompt", ["caf\u00e9", "\u4f60\u597d\u4e16\u754c", "na\u00efve r\u00e9sum\u00e9", "\U0001f600", "line1\nline2"] + "prompt", + [ + "\x1f", + "\x7f", + "caf\u00e9", + "\u4f60\u597d\u4e16\u754c", + "na\u00efve r\u00e9sum\u00e9", + "\U0001f600", + "line1\nline2", + ], ) -async def test_ascii_smuggler_encode_unrepresentable_chars_raise(prompt: str): +async def test_ascii_smuggler_encode_unrepresentable_chars_raise(prompt: str) -> None: # Only the ASCII printable range (0x20-0x7E) maps to Unicode tags. Characters outside it used to be # logged and dropped while convert_async still returned a success result, so a non-ASCII (or multi-line) # objective was smuggled as a corrupted payload and an attack could be scored against a target that was @@ -61,7 +70,7 @@ async def test_ascii_smuggler_encode_unrepresentable_chars_raise(prompt: str): await converter.convert_async(prompt=prompt, input_type="text") -async def test_ascii_smuggler_encode_printable_boundaries_do_not_raise(): +async def test_ascii_smuggler_encode_printable_boundaries_do_not_raise() -> None: # 0x20 (space) and 0x7E (~) are the inclusive edges of the representable range and must still encode. converter = AsciiSmugglerConverter(action="encode") result = await converter.convert_async(prompt=" ~", input_type="text")