Skip to content

Commit 3253c1e

Browse files
FEAT: Add BidiConverter for Arabic adversarial Unicode manipulation (#1832)
Co-authored-by: Roman Lutz <romanlutz13@gmail.com>
1 parent 860d0c5 commit 3253c1e

7 files changed

Lines changed: 226 additions & 52 deletions

File tree

doc/code/converters/0_converters.ipynb

Lines changed: 53 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -84,58 +84,59 @@
8484
"23 text text AtbashConverter\n",
8585
"24 text text Base2048Converter\n",
8686
"25 text text Base64Converter\n",
87-
"26 text text BinAsciiConverter\n",
88-
"27 text text BinaryConverter\n",
89-
"28 text text BrailleConverter\n",
90-
"29 text text CaesarConverter\n",
91-
"30 text text CharSwapConverter\n",
92-
"31 text text CharacterSpaceConverter\n",
93-
"32 text text CodeChameleonConverter\n",
94-
"33 text text ColloquialWordswapConverter\n",
95-
"34 text text DenylistConverter\n",
96-
"35 text text DiacriticConverter\n",
97-
"36 text text EcojiConverter\n",
98-
"37 text text EmojiConverter\n",
99-
"38 text text FirstLetterConverter\n",
100-
"39 text text FlipConverter\n",
101-
"40 text text ImagePromptStyleConverter\n",
102-
"41 text text InsertPunctuationConverter\n",
103-
"42 text text JsonStringConverter\n",
104-
"43 text text LLMGenericTextConverter\n",
105-
"44 text text LeetspeakConverter\n",
106-
"45 text text MaliciousQuestionGeneratorConverter\n",
107-
"46 text text MathObfuscationConverter\n",
108-
"47 text text MathPromptConverter\n",
109-
"48 text text MorseConverter\n",
110-
"49 text text NatoConverter\n",
111-
"50 text text NegationTrapConverter\n",
112-
"51 text text NoiseConverter\n",
113-
"52 text text PersuasionConverter\n",
114-
"53 text text ROT13Converter\n",
115-
"54 text text RandomCapitalLettersConverter\n",
116-
"55 text text RandomTranslationConverter\n",
117-
"56 text text RepeatTokenConverter\n",
118-
"57 text text ScientificTranslationConverter\n",
119-
"58 text text SearchReplaceConverter\n",
120-
"59 text text SelectiveTextConverter\n",
121-
"60 text text SneakyBitsSmugglerConverter\n",
122-
"61 text text StringJoinConverter\n",
123-
"62 text text SuffixAppendConverter\n",
124-
"63 text text SuperscriptConverter\n",
125-
"64 text text TemplateSegmentConverter\n",
126-
"65 text text TenseConverter\n",
127-
"66 text text TextJailbreakConverter\n",
128-
"67 text text ToneConverter\n",
129-
"68 text text ToxicSentenceGeneratorConverter\n",
130-
"69 text text TranslationConverter\n",
131-
"70 text text UnicodeConfusableConverter\n",
132-
"71 text text UnicodeReplacementConverter\n",
133-
"72 text text UnicodeSubstitutionConverter\n",
134-
"73 text text UrlConverter\n",
135-
"74 text text VariationConverter\n",
136-
"75 text text VariationSelectorSmugglerConverter\n",
137-
"76 text text ZalgoConverter\n",
138-
"77 text text ZeroWidthConverter\n"
87+
"26 text text BidiConverter\n",
88+
"27 text text BinAsciiConverter\n",
89+
"28 text text BinaryConverter\n",
90+
"29 text text BrailleConverter\n",
91+
"30 text text CaesarConverter\n",
92+
"31 text text CharSwapConverter\n",
93+
"32 text text CharacterSpaceConverter\n",
94+
"33 text text CodeChameleonConverter\n",
95+
"34 text text ColloquialWordswapConverter\n",
96+
"35 text text DenylistConverter\n",
97+
"36 text text DiacriticConverter\n",
98+
"37 text text EcojiConverter\n",
99+
"38 text text EmojiConverter\n",
100+
"39 text text FirstLetterConverter\n",
101+
"40 text text FlipConverter\n",
102+
"41 text text ImagePromptStyleConverter\n",
103+
"42 text text InsertPunctuationConverter\n",
104+
"43 text text JsonStringConverter\n",
105+
"44 text text LLMGenericTextConverter\n",
106+
"45 text text LeetspeakConverter\n",
107+
"46 text text MaliciousQuestionGeneratorConverter\n",
108+
"47 text text MathObfuscationConverter\n",
109+
"48 text text MathPromptConverter\n",
110+
"49 text text MorseConverter\n",
111+
"50 text text NatoConverter\n",
112+
"51 text text NegationTrapConverter\n",
113+
"52 text text NoiseConverter\n",
114+
"53 text text PersuasionConverter\n",
115+
"54 text text ROT13Converter\n",
116+
"55 text text RandomCapitalLettersConverter\n",
117+
"56 text text RandomTranslationConverter\n",
118+
"57 text text RepeatTokenConverter\n",
119+
"58 text text ScientificTranslationConverter\n",
120+
"59 text text SearchReplaceConverter\n",
121+
"60 text text SelectiveTextConverter\n",
122+
"61 text text SneakyBitsSmugglerConverter\n",
123+
"62 text text StringJoinConverter\n",
124+
"63 text text SuffixAppendConverter\n",
125+
"64 text text SuperscriptConverter\n",
126+
"65 text text TemplateSegmentConverter\n",
127+
"66 text text TenseConverter\n",
128+
"67 text text TextJailbreakConverter\n",
129+
"68 text text ToneConverter\n",
130+
"69 text text ToxicSentenceGeneratorConverter\n",
131+
"70 text text TranslationConverter\n",
132+
"71 text text UnicodeConfusableConverter\n",
133+
"72 text text UnicodeReplacementConverter\n",
134+
"73 text text UnicodeSubstitutionConverter\n",
135+
"74 text text UrlConverter\n",
136+
"75 text text VariationConverter\n",
137+
"76 text text VariationSelectorSmugglerConverter\n",
138+
"77 text text ZalgoConverter\n",
139+
"78 text text ZeroWidthConverter\n"
139140
]
140141
}
141142
],

doc/code/converters/1_text_to_text_converters.ipynb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@
273273
"source": [
274274
"from pyrit.prompt_converter import (\n",
275275
" AnsiAttackConverter,\n",
276+
" BidiConverter,\n",
276277
" CharacterSpaceConverter,\n",
277278
" CharSwapConverter,\n",
278279
" CodeChameleonConverter,\n",
@@ -311,6 +312,9 @@
311312
"# Character Space [@robustintelligence2024bypass] inserts spaces between characters\n",
312313
"print(\"Character Space:\", await CharacterSpaceConverter().convert_async(prompt=prompt)) # type: ignore\n",
313314
"print(\"Diacritic:\", await DiacriticConverter().convert_async(prompt=prompt)) # type: ignore\n",
315+
"\n",
316+
"# Bidi [@boucher2023trojan] wraps text in Unicode bidirectional control characters\n",
317+
"print(\"Bidi:\", await BidiConverter().convert_async(prompt=prompt)) # type: ignore\n",
314318
"print(\"Superscript:\", await SuperscriptConverter().convert_async(prompt=prompt)) # type: ignore\n",
315319
"print(\"Zalgo:\", await ZalgoConverter().convert_async(prompt=prompt)) # type: ignore\n",
316320
"\n",

doc/code/converters/1_text_to_text_converters.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
# %%
8888
from pyrit.prompt_converter import (
8989
AnsiAttackConverter,
90+
BidiConverter,
9091
CharacterSpaceConverter,
9192
CharSwapConverter,
9293
CodeChameleonConverter,
@@ -125,6 +126,9 @@
125126
# Character Space [@robustintelligence2024bypass] inserts spaces between characters
126127
print("Character Space:", await CharacterSpaceConverter().convert_async(prompt=prompt)) # type: ignore
127128
print("Diacritic:", await DiacriticConverter().convert_async(prompt=prompt)) # type: ignore
129+
130+
# Bidi [@boucher2023trojan] wraps text in Unicode bidirectional control characters
131+
print("Bidi:", await BidiConverter().convert_async(prompt=prompt)) # type: ignore
128132
print("Superscript:", await SuperscriptConverter().convert_async(prompt=prompt)) # type: ignore
129133
print("Zalgo:", await ZalgoConverter().convert_async(prompt=prompt)) # type: ignore
130134

doc/references.bib

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,14 @@ @misc{microsoft2024skeletonkey
377377
note = {Microsoft Security Blog},
378378
}
379379

380+
@misc{boucher2023trojan,
381+
title = {Trojan Source: Invisible Vulnerabilities},
382+
author = {Nicholas Boucher and Ross Anderson},
383+
year = {2023},
384+
url = {https://trojansource.codes/},
385+
note = {CVE-2021-42574, USENIX Security Symposium},
386+
}
387+
380388
@misc{embracethered2024unicode,
381389
title = {Hiding and Finding Text with Unicode Tags},
382390
author = {Johann Rehberger},

pyrit/prompt_converter/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from pyrit.prompt_converter.azure_speech_text_to_audio_converter import AzureSpeechTextToAudioConverter
2626
from pyrit.prompt_converter.base64_converter import Base64Converter
2727
from pyrit.prompt_converter.base2048_converter import Base2048Converter
28+
from pyrit.prompt_converter.bidi_converter import BidiConverter
2829
from pyrit.prompt_converter.bin_ascii_converter import BinAsciiConverter
2930
from pyrit.prompt_converter.binary_converter import BinaryConverter
3031
from pyrit.prompt_converter.braille_converter import BrailleConverter
@@ -156,6 +157,7 @@ def __getattr__(name: str) -> object:
156157
"AzureSpeechTextToAudioConverter",
157158
"Base2048Converter",
158159
"Base64Converter",
160+
"BidiConverter",
159161
"BinAsciiConverter",
160162
"BinaryConverter",
161163
"BrailleConverter",
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
import logging
5+
from typing import ClassVar, Literal
6+
7+
from pyrit.identifiers import ComponentIdentifier
8+
from pyrit.models import PromptDataType
9+
from pyrit.prompt_converter.prompt_converter import ConverterResult, PromptConverter
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class BidiConverter(PromptConverter):
15+
"""
16+
Wraps text in Unicode bidirectional control characters.
17+
18+
The converter surrounds the prompt with a matched pair of bidirectional formatting code points
19+
so that the logical (stored) code point order can differ from the order a human reader sees
20+
rendered. This is the family of manipulation behind the "Trojan Source" findings
21+
(CVE-2021-42574). The transformation is deterministic: no language model or randomness is
22+
involved, so the same input and scheme always produce the same output.
23+
24+
Schemes (per the Unicode Bidirectional Algorithm, UAX #9):
25+
- ``"override"``: RIGHT-TO-LEFT OVERRIDE (U+202E) ... POP DIRECTIONAL FORMATTING (U+202C).
26+
- ``"embedding"``: RIGHT-TO-LEFT EMBEDDING (U+202B) ... POP DIRECTIONAL FORMATTING (U+202C).
27+
- ``"isolate"``: RIGHT-TO-LEFT ISOLATE (U+2067) ... POP DIRECTIONAL ISOLATE (U+2069).
28+
29+
References:
30+
- Boucher and Anderson, "Trojan Source: Invisible Vulnerabilities" (CVE-2021-42574),
31+
https://trojansource.codes/
32+
- Unicode Standard Annex #9, "Unicode Bidirectional Algorithm",
33+
https://www.unicode.org/reports/tr9/
34+
"""
35+
36+
SUPPORTED_INPUT_TYPES = ("text",)
37+
SUPPORTED_OUTPUT_TYPES = ("text",)
38+
39+
# Scheme name mapped to its (opening, closing) control characters, built from code points to
40+
# keep the source file pure ASCII
41+
_SCHEMES: ClassVar[dict[str, tuple[str, str]]] = {
42+
"override": (chr(0x202E), chr(0x202C)),
43+
"embedding": (chr(0x202B), chr(0x202C)),
44+
"isolate": (chr(0x2067), chr(0x2069)),
45+
}
46+
47+
def __init__(self, *, scheme: Literal["override", "embedding", "isolate"] = "override") -> None:
48+
"""
49+
Initialize the converter with the bidirectional control scheme.
50+
51+
Args:
52+
scheme (Literal["override", "embedding", "isolate"]): The bidirectional control scheme
53+
used to wrap the prompt. Defaults to ``"override"``.
54+
55+
Raises:
56+
ValueError: If ``scheme`` is not recognized.
57+
"""
58+
super().__init__()
59+
60+
if scheme not in self._SCHEMES:
61+
raise ValueError(f"Scheme '{scheme}' not recognized. Choose from {list(self._SCHEMES)}.")
62+
63+
self._scheme = scheme
64+
65+
def _build_identifier(self) -> ComponentIdentifier:
66+
"""
67+
Build the converter identifier with the bidi scheme parameter.
68+
69+
Returns:
70+
ComponentIdentifier: The identifier for this converter.
71+
"""
72+
return self._create_identifier(params={"scheme": self._scheme})
73+
74+
async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text") -> ConverterResult:
75+
"""
76+
Convert the given prompt by wrapping it in bidirectional control characters.
77+
78+
Args:
79+
prompt (str): The prompt to be converted.
80+
input_type (PromptDataType): The type of input data.
81+
82+
Returns:
83+
ConverterResult: The result containing the wrapped text, or an empty string if the
84+
prompt is empty.
85+
86+
Raises:
87+
ValueError: If the input type is not supported.
88+
"""
89+
if not self.input_supported(input_type):
90+
raise ValueError("Input type not supported")
91+
92+
if not prompt:
93+
return ConverterResult(output_text="", output_type="text")
94+
95+
prefix, suffix = self._SCHEMES[self._scheme]
96+
return ConverterResult(output_text=f"{prefix}{prompt}{suffix}", output_type="text")
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
import pytest
5+
6+
from pyrit.prompt_converter import BidiConverter, ConverterResult
7+
8+
# Bidirectional control characters, built from code points to keep this file pure ASCII
9+
RLO = chr(0x202E) # Right-to-left override
10+
RLE = chr(0x202B) # Right-to-left embedding
11+
PDF = chr(0x202C) # Pop directional formatting
12+
RLI = chr(0x2067) # Right-to-left isolate
13+
PDI = chr(0x2069) # Pop directional isolate
14+
15+
16+
def test_input_supported():
17+
converter = BidiConverter()
18+
assert converter.input_supported("text") is True
19+
assert converter.input_supported("image") is False
20+
21+
22+
async def test_default_scheme_wraps_in_rlo_override():
23+
result = await BidiConverter().convert_async(prompt="abc", input_type="text")
24+
assert isinstance(result, ConverterResult)
25+
assert result.output_type == "text"
26+
assert result.output_text == f"{RLO}abc{PDF}"
27+
28+
29+
async def test_embedding_scheme():
30+
result = await BidiConverter(scheme="embedding").convert_async(prompt="abc")
31+
assert result.output_text == f"{RLE}abc{PDF}"
32+
33+
34+
async def test_isolate_scheme():
35+
result = await BidiConverter(scheme="isolate").convert_async(prompt="abc")
36+
assert result.output_text == f"{RLI}abc{PDI}"
37+
38+
39+
async def test_empty_prompt_returns_empty():
40+
result = await BidiConverter().convert_async(prompt="")
41+
assert result.output_text == ""
42+
43+
44+
@pytest.mark.parametrize("scheme", ["override", "embedding", "isolate"])
45+
async def test_conversion_is_deterministic(scheme):
46+
converter = BidiConverter(scheme=scheme)
47+
first = await converter.convert_async(prompt="some prompt")
48+
second = await converter.convert_async(prompt="some prompt")
49+
assert first.output_text == second.output_text
50+
51+
52+
def test_invalid_scheme_raises():
53+
with pytest.raises(ValueError):
54+
BidiConverter(scheme="nonsense")
55+
56+
57+
async def test_input_type_not_supported_raises():
58+
with pytest.raises(ValueError):
59+
await BidiConverter().convert_async(prompt="abc", input_type="image")

0 commit comments

Comments
 (0)