|
| 1 | +"""Input sanitization to detect and block obfuscated prompt injection attempts. |
| 2 | +
|
| 3 | +Addresses pentest finding OFFSEC-307 (LCORE-2749, CVSS 9.6 Critical): |
| 4 | +attackers can bypass content filters by encoding malicious instructions |
| 5 | +in unusual Unicode blocks (Elder Futhark, Mathematical Alphanumeric |
| 6 | +Symbols) or binary/hex representation. |
| 7 | +
|
| 8 | +This module provides: |
| 9 | +- Unicode NFC normalization |
| 10 | +- Detection of obfuscation techniques (unusual Unicode blocks, binary |
| 11 | + encoding, hex encoding, XML tag injection patterns) |
| 12 | +
|
| 13 | +All checks are CPU-only stdlib operations with negligible latency (< 1ms). |
| 14 | +""" |
| 15 | + |
| 16 | +import re |
| 17 | +import unicodedata |
| 18 | +from typing import Optional |
| 19 | + |
| 20 | +from log import get_logger |
| 21 | + |
| 22 | +logger = get_logger(__name__) |
| 23 | + |
| 24 | +# --------------------------------------------------------------------------- |
| 25 | +# Unicode block ranges considered obfuscation vectors |
| 26 | +# --------------------------------------------------------------------------- |
| 27 | +# Each tuple is (start, end, label) inclusive. |
| 28 | +_SUSPICIOUS_UNICODE_RANGES: list[tuple[int, int, str]] = [ |
| 29 | + # Runic block — includes Elder Futhark (used in OFFSEC-307) |
| 30 | + (0x16A0, 0x16FF, "Runic"), |
| 31 | + # Mathematical Alphanumeric Symbols — bold/italic/script variants |
| 32 | + # of Latin letters that visually resemble ASCII but bypass filters |
| 33 | + (0x1D400, 0x1D7FF, "Mathematical Alphanumeric Symbols"), |
| 34 | + # Enclosed Alphanumerics (circled digits/letters) |
| 35 | + (0x2460, 0x24FF, "Enclosed Alphanumerics"), |
| 36 | + # Fullwidth Latin letters only (A-Z, a-z) — visually similar to ASCII. |
| 37 | + # Excludes fullwidth punctuation (U+FF01-FF20, U+FF3B-FF40, U+FF5B-FF5E) |
| 38 | + # which may appear in legitimate CJK-context text. |
| 39 | + (0xFF21, 0xFF3A, "Fullwidth Latin uppercase"), |
| 40 | + (0xFF41, 0xFF5A, "Fullwidth Latin lowercase"), |
| 41 | +] |
| 42 | + |
| 43 | +# --------------------------------------------------------------------------- |
| 44 | +# Regex patterns for binary/hex encoding detection |
| 45 | +# --------------------------------------------------------------------------- |
| 46 | +# Binary: 8+ groups of 8 binary digits (space-separated bytes) |
| 47 | +_BINARY_PATTERN = re.compile(r"(?:[01]{8}[\s]+){3,}[01]{8}") |
| 48 | + |
| 49 | +# Hex escape sequences: \x41\x42 or 0x41 0x42 patterns |
| 50 | +_HEX_ESCAPE_PATTERN = re.compile(r"(?:\\x[0-9a-fA-F]{2}){4,}") |
| 51 | +_HEX_PREFIX_PATTERN = re.compile(r"(?:0x[0-9a-fA-F]{2}[\s,]+){4,}") |
| 52 | + |
| 53 | +# --------------------------------------------------------------------------- |
| 54 | +# XML/markup injection patterns (per OffSec recommendation) |
| 55 | +# --------------------------------------------------------------------------- |
| 56 | +_XML_INJECTION_PATTERN = re.compile( |
| 57 | + r"<\s*/?(?:ac:|invoke|function_call|tool_call|system|assistant)[^>]*>", |
| 58 | + re.IGNORECASE, |
| 59 | +) |
| 60 | + |
| 61 | + |
| 62 | +def normalize_unicode(text: str) -> str: |
| 63 | + """Normalize text to Unicode NFC form. |
| 64 | +
|
| 65 | + NFC normalization ensures that composed and decomposed Unicode |
| 66 | + representations are treated identically. For example, 'é' as a |
| 67 | + single codepoint (U+00E9) and 'e' + combining accent (U+0065 |
| 68 | + U+0301) are normalized to the same form. |
| 69 | +
|
| 70 | + Parameters: |
| 71 | + text: The input text to normalize. |
| 72 | +
|
| 73 | + Returns: |
| 74 | + NFC-normalized text. |
| 75 | + """ |
| 76 | + return unicodedata.normalize("NFC", text) |
| 77 | + |
| 78 | + |
| 79 | +def _check_suspicious_unicode(text: str) -> Optional[str]: |
| 80 | + """Check for characters from Unicode blocks used for obfuscation. |
| 81 | +
|
| 82 | + Parameters: |
| 83 | + text: The input text to check. |
| 84 | +
|
| 85 | + Returns: |
| 86 | + Description of the detected block, or None if clean. |
| 87 | + """ |
| 88 | + for char in text: |
| 89 | + codepoint = ord(char) |
| 90 | + for start, end, label in _SUSPICIOUS_UNICODE_RANGES: |
| 91 | + if start <= codepoint <= end: |
| 92 | + return ( |
| 93 | + f"Input contains characters from the {label} Unicode " |
| 94 | + f"block (U+{codepoint:04X}), which may be used to " |
| 95 | + f"obfuscate instructions." |
| 96 | + ) |
| 97 | + return None |
| 98 | + |
| 99 | + |
| 100 | +def _check_binary_encoding(text: str) -> Optional[str]: |
| 101 | + """Check for binary-encoded content (sequences of 0s and 1s). |
| 102 | +
|
| 103 | + Parameters: |
| 104 | + text: The input text to check. |
| 105 | +
|
| 106 | + Returns: |
| 107 | + Description if binary encoding is detected, or None if clean. |
| 108 | + """ |
| 109 | + if _BINARY_PATTERN.search(text): |
| 110 | + return "Input appears to contain binary-encoded content." |
| 111 | + return None |
| 112 | + |
| 113 | + |
| 114 | +def _check_hex_encoding(text: str) -> Optional[str]: |
| 115 | + """Check for hex-encoded content (escape sequences or hex prefixes). |
| 116 | +
|
| 117 | + Parameters: |
| 118 | + text: The input text to check. |
| 119 | +
|
| 120 | + Returns: |
| 121 | + Description if hex encoding is detected, or None if clean. |
| 122 | + """ |
| 123 | + if _HEX_ESCAPE_PATTERN.search(text): |
| 124 | + return "Input appears to contain hex-encoded escape sequences." |
| 125 | + if _HEX_PREFIX_PATTERN.search(text): |
| 126 | + return "Input appears to contain hex-encoded content." |
| 127 | + return None |
| 128 | + |
| 129 | + |
| 130 | +def _check_xml_injection(text: str) -> Optional[str]: |
| 131 | + """Check for XML/markup tag patterns used for tool-call injection. |
| 132 | +
|
| 133 | + Parameters: |
| 134 | + text: The input text to check. |
| 135 | +
|
| 136 | + Returns: |
| 137 | + Description if suspicious XML tags are detected, or None if clean. |
| 138 | + """ |
| 139 | + if _XML_INJECTION_PATTERN.search(text): |
| 140 | + return "Input contains suspicious XML/markup injection tags." |
| 141 | + return None |
| 142 | + |
| 143 | + |
| 144 | +def detect_obfuscation(text: str) -> Optional[str]: |
| 145 | + """Check input text for obfuscation techniques. |
| 146 | +
|
| 147 | + Runs all detection checks and returns the first match. |
| 148 | +
|
| 149 | + Parameters: |
| 150 | + text: The input text to check. |
| 151 | +
|
| 152 | + Returns: |
| 153 | + Description of detected obfuscation, or None if the input is clean. |
| 154 | + """ |
| 155 | + checks = [ |
| 156 | + _check_suspicious_unicode, |
| 157 | + _check_binary_encoding, |
| 158 | + _check_hex_encoding, |
| 159 | + _check_xml_injection, |
| 160 | + ] |
| 161 | + for check in checks: |
| 162 | + result = check(text) |
| 163 | + if result is not None: |
| 164 | + return result |
| 165 | + return None |
| 166 | + |
| 167 | + |
| 168 | +def sanitize_input(text: str) -> tuple[str, Optional[str]]: |
| 169 | + """Normalize and check input text for obfuscation. |
| 170 | +
|
| 171 | + First normalizes the text to Unicode NFC form, then runs |
| 172 | + obfuscation detection checks. |
| 173 | +
|
| 174 | + Parameters: |
| 175 | + text: The raw user input text. |
| 176 | +
|
| 177 | + Returns: |
| 178 | + Tuple of (normalized_text, rejection_reason). |
| 179 | + If rejection_reason is None, the input is clean and |
| 180 | + normalized_text should be used for further processing. |
| 181 | + If rejection_reason is not None, the input should be |
| 182 | + rejected with the given reason. |
| 183 | + """ |
| 184 | + normalized = normalize_unicode(text) |
| 185 | + rejection_reason = detect_obfuscation(normalized) |
| 186 | + |
| 187 | + if rejection_reason: |
| 188 | + logger.warning( |
| 189 | + "Input rejected by sanitization: %s", |
| 190 | + rejection_reason, |
| 191 | + ) |
| 192 | + |
| 193 | + return normalized, rejection_reason |
0 commit comments