浅析Python中的“斜体字符”绕过
浅析Python中的“斜体字符”绕过
什么是“斜体字符”?
简单来说,在 Unicode 标准中,有一组专门用于数学排版的字符,例如:
- 普通字母:a, b, c
- 数学斜体小写字母:𝑎, 𝑏, 𝑐(U+1D44E–U+1D467)
- 数学粗体、斜体、花体等变体也都有独立编码
这些字符看起来是斜体,但它们是独立的 Unicode 码点,不是通过格式化得到的。
本文所说的“斜体字”是泛指这一类特殊变体字符,而不单单只是斜的那几个,但为了方便叙述,以下都统一叫做斜体字吧。
斜体字绕过,本质上是一种特殊的unicode编码绕过技巧。
“斜体字符”为什么可以被利用?
这本质上是统一码兼容性的特性,它确保在可能具有不同视觉外观或行为的字符或字符序列之间,表示相同的抽象字符。具体的表现,就是将一些斜体字符转化为普通的常见字符,例如上文中𝓼实际代替了s,这是一种规范化处理。
可参照文档:https://www.unicode.org/reports/tr15/
这种规范化形式有四种:
| 形式 | 描述 | 举例 |
|---|---|---|
| Normalization Form Canonical Decomposition(NFD) | 分解规范形式 | é → e + ◌́ |
| Normalization Form Canonical Composition(NFC) | 合成规范形式 | e + ◌́ → é |
| Normalization Form Compatibility Decomposition(NFKD) | 兼容分解形式 | ffi → f + f + i |
| Normalization Form Compatibility Composition(NFKC) | 兼容合成形式 | fi → f + i |
简单解释一下这四种规范化形式:
NFD
- 将所有可分解的字符按规范等价分解为基本字符 + 组合标记
- 不重新排序组合标记(保持原始顺序,但符合规范组合类规则)
比如说:“é”(U+00E9) → “e”(U+0065) + “◌́”(U+0301)
就是把一个复杂字符拆分成多个简单字符,是一种完全分解
NFC
- 先执行 NFD(完全分解),然后按规范等价尽可能合成为预组合字符
- 是 Web 和操作系统中最常用的规范化形式
- 使用最少的码位构成等价的字符串
比如说:“e” + “◌́” → “é”(如果存在对应的预组合字符)
NFKD
- 不仅进行规范分解,还进行兼容性分解(compatibility decomposition)。
- 会将“看起来不同但语义相近”的字符也展开(如全角/半角、上标数字、连字等)。
比如说:
- “ffi”(U+FB03,连字) → “f” + “f” + “i”
- “①”(U+2460,带圈数字) → “1”
- “A”(全角 A, U+FF21) → “A”(半角 A, U+0041)
NFKC
- 先执行 NFKD(兼容分解),然后像 NFC 一样尽可能合成。
- 结果是兼容等价下的最紧凑形式。
比如说:
- “①” → “1”(无法再合成)
- “fi”(U+FB01) → “f” + “i” → 若有预组合则合成(但“fi”无预组合字符,所以保持分开)
总结来说,KFD和KFC都是规范等价的,视觉和语义完全相同;而NFKD和NFKC是兼容等价的,可能改变语义或外观。
这是python3当中对于Unicode编码的说明:https://docs.python.org/zh-cn/3/howto/unicode.html
通过以下代码我们可以进行测试:
1 | import unicodedata |

可以看到KFC和KFD保持了原有信息,而NFKC和NFKD则是被转成了简单字符的形式。
在这种情况下,如果WAF仅仅只是进行了关键词匹配的过滤,就存在被绕过的可能,有点像是一种弱类型比较。
为什么需要有Unicode规范化?
因为同一个字符在 Unicode 中可能有多种不同的编码表示(例如 “é” 可以是一个预组字符,也可以是 “e” 加一个重音符号),虽然显示效果一样,但程序会认为它们不相等。
Unicode 规范化就是把不同表示统一成一种标准形式(如 NFC),确保:
字符串比较、查找、去重等操作结果正确;
不同来源的文本能一致处理。
简言之:让“看起来一样”的文本,在程序里也“真的相等”。
在Python中怎么利用?
有以下这样的简单测试:
1 | import unicodedata |

ᵗ就是t的一种变体字符,它可以被解析当成t使用,
1 | import unicodedata |

而字符串类型数据在实际表现上则采用NFD/NFC进行处理,内容没有被改变。
具体解释来说,在python3当中,我要执行一个函数,python3会按照LEGB规则在各作用域找到对应的函数名去进行调用,也就是通过名字来找函数对象。而在python3当中,我们可以认为,设置的函数名在实际表现上就相当于经过了NFKC/NFKD处理:
1 | class A(): |

结果是一样的,在python看来,函数名tesᵗ和test表示的就是一个意思,因为他们经过了NFKC/NFKD处理后就是相同的。
1 | class A(): |
的运行结果是:

也足以说明这一点。需要额外补充的是,这种字符替换并不适用于关键词,比如class,def,标识语法的特殊符号等。
斜体字可通过这几个网站进行获取:
此外,利用NFKD/NFKC不仅可以进行字符替换,在某些时候,还可以绕过某些字符串长度限制,比如以下这个例子:
1 | import unicodedata |

可以看到是减少了3个字符的,但是由于这种字符组合词比较少,所有这种利用方式并没有多少实战价值,算是提供一种新思路吧。
通过以下代码,简单遍历了一下可用的连体字符:
1 | import string |
1 | {'IJ': 'IJ', 'ij': 'ij', 'LJ': 'LJ', 'Lj': 'Lj', 'lj': 'lj', 'NJ': 'NJ', 'Nj': 'Nj', 'nj': 'nj', 'DZ': 'DZ', 'Dz': 'Dz', 'dz': 'dz', 'Rs': '₨', 'No': '№', 'SM': '℠', 'TEL': '℡', 'TM': '™', 'FAX': '℻', 'II': 'Ⅱ', 'III': 'Ⅲ', 'IV': 'Ⅳ', 'VI': 'Ⅵ', 'VII': 'Ⅶ', 'VIII': 'Ⅷ', 'IX': 'Ⅸ', 'XI': 'Ⅺ', 'XII': 'Ⅻ', 'ii': 'ⅱ', 'iii': 'ⅲ', 'iv': 'ⅳ', 'vi': 'ⅵ', 'vii': 'ⅶ', 'viii': 'ⅷ', 'ix': 'ⅸ', 'xi': 'ⅺ', 'xii': 'ⅻ', '10': '⑩', '11': '⑪', '12': '⑫', '13': '⑬', '14': '⑭', '15': '⑮', '16': '⑯', '17': '⑰', '18': '⑱', '19': '⑲', '20': '⑳', 'PTE': '㉐', '21': '㉑', '22': '㉒', '23': '㉓', '24': '㉔', '25': '㉕', '26': '㉖', '27': '㉗', '28': '㉘', '29': '㉙', '30': '㉚', '31': '㉛', '32': '㉜', '33': '㉝', '34': '㉞', '35': '㉟', '36': '㊱', '37': '㊲', '38': '㊳', '39': '㊴', '40': '㊵', '41': '㊶', '42': '㊷', '43': '㊸', '44': '㊹', '45': '㊺', '46': '㊻', '47': '㊼', '48': '㊽', '49': '㊾', '50': '㊿', 'Hg': '㋌', 'erg': '㋍', 'eV': '㋎', 'LTD': '㋏', 'hPa': '㍱', 'da': '㍲', 'AU': '㍳', 'bar': '㍴', 'oV': '㍵', 'pc': '㍶', 'dm': '㍷', 'dm2': '㍸', 'dm3': '㍹', 'IU': '㍺', 'pA': '㎀', 'nA': '㎁', 'mA': '㎃', 'kA': '㎄', 'KB': '㎅', 'MB': '㎆', 'GB': '㎇', 'cal': '㎈', 'kcal': '㎉', 'pF': '㎊', 'nF': '㎋', 'mg': '㎎', 'kg': '㎏', 'Hz': '㎐', 'kHz': '㎑', 'MHz': '㎒', 'GHz': '㎓', 'THz': '㎔', 'ml': '㎖', 'dl': '㎗', 'kl': '㎘', 'fm': '㎙', 'nm': '㎚', 'mm': '㎜', 'cm': '㎝', 'km': '㎞', 'mm2': '㎟', 'cm2': '㎠', 'm2': '㎡', 'km2': '㎢', 'mm3': '㎣', 'cm3': '㎤', 'm3': '㎥', 'km3': '㎦', 'Pa': '㎩', 'kPa': '㎪', 'MPa': '㎫', 'GPa': '㎬', 'rad': '㎭', 'ps': '㎰', 'ns': '㎱', 'ms': '㎳', 'pV': '㎴', 'nV': '㎵', 'mV': '㎷', 'kV': '㎸', 'MV': '🅋', 'pW': '㎺', 'nW': '㎻', 'mW': '㎽', 'kW': '㎾', 'MW': '㎿', 'Bq': '㏃', 'cc': '㏄', 'cd': '㏅', 'dB': '㏈', 'Gy': '㏉', 'ha': '㏊', 'HP': '㏋', 'in': '㏌', 'KK': '㏍', 'KM': '㏎', 'kt': '㏏', 'lm': '㏐', 'ln': '㏑', 'log': '㏒', 'lx': '㏓', 'mb': '㏔', 'mil': '㏕', 'mol': '㏖', 'PH': '㏗', 'PPM': '㏙', 'PR': '㏚', 'sr': '㏛', 'Sv': '㏜', 'Wb': '㏝', 'gal': '㏿', 'ff': 'ff', 'fi': 'fi', 'fl': 'fl', 'ffi': 'ffi', 'ffl': 'ffl', 'st': 'st', 'CD': '🄭', 'WZ': '🄮', 'HV': '🅊', 'SD': '🅌', 'SS': '🅍', 'PPV': '🅎', 'WC': '🅏', 'MC': '🅪', 'MD': '🅫', 'MR': '🅬', 'DJ': '🆐'} |
在不同环境下(例如web框架flask),可能对于这些变体字符有提前处理,所以可替换字符不一定通用。
exec() 执行环境
在exec()默认执行环境下,一些已测可用的字符替换:
| 原始字符 | 变体字符 |
|---|---|
| 0 | 0,𝟎,𝟘,𝟢,𝟬,𝟶,🯰 |
| 1 | 1,𝟏,𝟙,𝟣,𝟭,𝟷,🯱 |
| 2 | 2,𝟐,𝟚,𝟤,𝟮,𝟸,🯲 |
| 3 | 3,𝟑,𝟛,𝟥,𝟯,𝟹,🯳 |
| 4 | 4,𝟒,𝟜,𝟦,𝟰,𝟺,🯴 |
| 5 | 5,𝟓,𝟝,𝟧,𝟱,𝟻,🯵 |
| 6 | 6,𝟔,𝟞,𝟨,𝟲,𝟼,🯶 |
| 7 | 7,𝟕,𝟟,𝟩,𝟳,𝟽,🯷 |
| 8 | 8,𝟖,𝟠,𝟪,𝟴,𝟾,🯸 |
| 9 | 9,𝟗,𝟡,𝟫,𝟵,𝟿,🯹 |
| A | ᴬ,A,𝐀,𝐴,𝑨,𝒜,𝓐,𝔄,𝔸,𝕬,𝖠,𝗔,𝘈,𝘼,𝙰 |
| B | ᴮ,ℬ,B,𝐁,𝐵,𝑩,𝓑,𝔅,𝔹,𝕭,𝖡,𝗕,𝘉,𝘽,𝙱 |
| C | ℂ,ℭ,Ⅽ,C,𝐂,𝐶,𝑪,𝒞,𝓒,𝕮,𝖢,𝗖,𝘊,𝘾,𝙲 |
| D | ᴰ,ⅅ,Ⅾ,D,𝐃,𝐷,𝑫,𝒟,𝓓,𝔇,𝔻,𝕯,𝖣,𝗗,𝘋,𝘿,𝙳 |
| E | ᴱ,ℰ,E,𝐄,𝐸,𝑬,𝓔,𝔈,𝔼,𝕰,𝖤,𝗘,𝘌,𝙀,𝙴 |
| F | ℱ,F,𝐅,𝐹,𝑭,𝓕,𝔉,𝔽,𝕱,𝖥,𝗙,𝘍,𝙁,𝙵 |
| G | ᴳ,G,𝐆,𝐺,𝑮,𝒢,𝓖,𝔊,𝔾,𝕲,𝖦,𝗚,𝘎,𝙂,𝙶 |
| H | ᴴ,ℋ,ℌ,ℍ,H,𝐇,𝐻,𝑯,𝓗,𝕳,𝖧,𝗛,𝘏,𝙃,𝙷 |
| I | ᴵ,ℐ,ℑ,Ⅰ,I,𝐈,𝐼,𝑰,𝓘,𝕀,𝕴,𝖨,𝗜,𝘐,𝙄,𝙸 |
| J | ᴶ,J,𝐉,𝐽,𝑱,𝒥,𝓙,𝔍,𝕁,𝕵,𝖩,𝗝,𝘑,𝙅,𝙹 |
| K | ᴷ,K,K,𝐊,𝐾,𝑲,𝒦,𝓚,𝔎,𝕂,𝕶,𝖪,𝗞,𝘒,𝙆,𝙺 |
| L | ᴸ,ℒ,Ⅼ,L,𝐋,𝐿,𝑳,𝓛,𝔏,𝕃,𝕷,𝖫,𝗟,𝘓,𝙇,𝙻 |
| M | ᴹ,ℳ,Ⅿ,M,𝐌,𝑀,𝑴,𝓜,𝔐,𝕄,𝕸,𝖬,𝗠,𝘔,𝙈,𝙼 |
| N | ᴺ,ℕ,N,𝐍,𝑁,𝑵,𝒩,𝓝,𝔑,𝕹,𝖭,𝗡,𝘕,𝙉,𝙽 |
| O | ᴼ,O,𝐎,𝑂,𝑶,𝒪,𝓞,𝔒,𝕆,𝕺,𝖮,𝗢,𝘖,𝙊,𝙾 |
| P | ᴾ,ℙ,P,𝐏,𝑃,𝑷,𝒫,𝓟,𝔓,𝕻,𝖯,𝗣,𝘗,𝙋,𝙿 |
| Q | ℚ,Q,𝐐,𝑄,𝑸,𝒬,𝓠,𝔔,𝕼,𝖰,𝗤,𝘘,𝙌,𝚀 |
| R | ᴿ,ℛ,ℜ,ℝ,R,𝐑,𝑅,𝑹,𝓡,𝕽,𝖱,𝗥,𝘙,𝙍,𝚁 |
| S | S,𝐒,𝑆,𝑺,𝒮,𝓢,𝔖,𝕊,𝕾,𝖲,𝗦,𝘚,𝙎,𝚂 |
| T | ᵀ,T,𝐓,𝑇,𝑻,𝒯,𝓣,𝔗,𝕋,𝕿,𝖳,𝗧,𝘛,𝙏,𝚃 |
| U | ᵁ,U,𝐔,𝑈,𝑼,𝒰,𝓤,𝔘,𝕌,𝖀,𝖴,𝗨,𝘜,𝙐,𝚄 |
| V | Ⅴ,ⱽ,V,𝐕,𝑉,𝑽,𝒱,𝓥,𝔙,𝕍,𝖁,𝖵,𝗩,𝘝,𝙑,𝚅 |
| W | ᵂ,W,𝐖,𝑊,𝑾,𝒲,𝓦,𝔚,𝕎,𝖂,𝖶,𝗪,𝘞,𝙒,𝚆 |
| X | Ⅹ,X,𝐗,𝑋,𝑿,𝒳,𝓧,𝔛,𝕏,𝖃,𝖷,𝗫,𝘟,𝙓,𝚇 |
| Y | Y,𝐘,𝑌,𝒀,𝒴,𝓨,𝔜,𝕐,𝖄,𝖸,𝗬,𝘠,𝙔,𝚈 |
| Z | ℤ,ℨ,Z,𝐙,𝑍,𝒁,𝒵,𝓩,𝖅,𝖹,𝗭,𝘡,𝙕,𝚉 |
| _ | ︳,︴,﹍,﹎,﹏,_ |
| a | ª,ᵃ,ₐ,a,𝐚,𝑎,𝒂,𝒶,𝓪,𝔞,𝕒,𝖆,𝖺,𝗮,𝘢,𝙖,𝚊 |
| b | ᵇ,b,𝐛,𝑏,𝒃,𝒷,𝓫,𝔟,𝕓,𝖇,𝖻,𝗯,𝘣,𝙗,𝚋 |
| c | ᶜ,ⅽ,c,𝐜,𝑐,𝒄,𝒸,𝓬,𝔠,𝕔,𝖈,𝖼,𝗰,𝘤,𝙘,𝚌 |
| d | ᵈ,ⅆ,ⅾ,d,𝐝,𝑑,𝒅,𝒹,𝓭,𝔡,𝕕,𝖉,𝖽,𝗱,𝘥,𝙙,𝚍 |
| e | ᵉ,ₑ,ℯ,ⅇ,e,𝐞,𝑒,𝒆,𝓮,𝔢,𝕖,𝖊,𝖾,𝗲,𝘦,𝙚,𝚎 |
| f | ᶠ,f,𝐟,𝑓,𝒇,𝒻,𝓯,𝔣,𝕗,𝖋,𝖿,𝗳,𝘧,𝙛,𝚏 |
| g | ᵍ,ℊ,g,𝐠,𝑔,𝒈,𝓰,𝔤,𝕘,𝖌,𝗀,𝗴,𝘨,𝙜,𝚐 |
| h | ʰ,ₕ,ℎ,h,𝐡,𝒉,𝒽,𝓱,𝔥,𝕙,𝖍,𝗁,𝗵,𝘩,𝙝,𝚑 |
| i | ᵢ,ⁱ,ℹ,ⅈ,ⅰ,i,𝐢,𝑖,𝒊,𝒾,𝓲,𝔦,𝕚,𝖎,𝗂,𝗶,𝘪,𝙞,𝚒 |
| j | ʲ,ⅉ,ⱼ,j,𝐣,𝑗,𝒋,𝒿,𝓳,𝔧,𝕛,𝖏,𝗃,𝗷,𝘫,𝙟,𝚓 |
| k | ᵏ,ₖ,k,𝐤,𝑘,𝒌,𝓀,𝓴,𝔨,𝕜,𝖐,𝗄,𝗸,𝘬,𝙠,𝚔 |
| l | ˡ,ₗ,ℓ,ⅼ,l,𝐥,𝑙,𝒍,𝓁,𝓵,𝔩,𝕝,𝖑,𝗅,𝗹,𝘭,𝙡,𝚕 |
| m | ᵐ,ₘ,ⅿ,m,𝐦,𝑚,𝒎,𝓂,𝓶,𝔪,𝕞,𝖒,𝗆,𝗺,𝘮,𝙢,𝚖 |
| n | ⁿ,ₙ,n,𝐧,𝑛,𝒏,𝓃,𝓷,𝔫,𝕟,𝖓,𝗇,𝗻,𝘯,𝙣,𝚗 |
| o | º,ᵒ,ₒ,ℴ,o,𝐨,𝑜,𝒐,𝓸,𝔬,𝕠,𝖔,𝗈,𝗼,𝘰,𝙤,𝚘 |
| p | ᵖ,ₚ,p,𝐩,𝑝,𝒑,𝓅,𝓹,𝔭,𝕡,𝖕,𝗉,𝗽,𝘱,𝙥,𝚙 |
| q | q,𝐪,𝑞,𝒒,𝓆,𝓺,𝔮,𝕢,𝖖,𝗊,𝗾,𝘲,𝙦,𝚚 |
| r | ʳ,ᵣ,r,𝐫,𝑟,𝒓,𝓇,𝓻,𝔯,𝕣,𝖗,𝗋,𝗿,𝘳,𝙧,𝚛 |
| s | ſ,ˢ,ₛ,s,𝐬,𝑠,𝒔,𝓈,𝓼,𝔰,𝕤,𝖘,𝗌,𝘀,𝘴,𝙨,𝚜 |
| t | ᵗ,ₜ,t,𝐭,𝑡,𝒕,𝓉,𝓽,𝔱,𝕥,𝖙,𝗍,𝘁,𝘵,𝙩,𝚝 |
| u | ᵘ,ᵤ,u,𝐮,𝑢,𝒖,𝓊,𝓾,𝔲,𝕦,𝖚,𝗎,𝘂,𝘶,𝙪,𝚞 |
| v | ᵛ,ᵥ,ⅴ,v,𝐯,𝑣,𝒗,𝓋,𝓿,𝔳,𝕧,𝖛,𝗏,𝘃,𝘷,𝙫,𝚟 |
| w | ʷ,w,𝐰,𝑤,𝒘,𝓌,𝔀,𝔴,𝕨,𝖜,𝗐,𝘄,𝘸,𝙬,𝚠 |
| x | ˣ,ₓ,ⅹ,x,𝐱,𝑥,𝒙,𝓍,𝔁,𝔵,𝕩,𝖝,𝗑,𝘅,𝘹,𝙭,𝚡 |
| y | ʸ,y,𝐲,𝑦,𝒚,𝓎,𝔂,𝔶,𝕪,𝖞,𝗒,𝘆,𝘺,𝙮,𝚢 |
| z | ᶻ,z,𝐳,𝑧,𝒛,𝓏,𝔃,𝔷,𝕫,𝖟,𝗓,𝘇,𝘻,𝙯,𝚣 |
bottle框架(前端传参时):
由于URL编码问题,这些变体字符往往是几个url编码串联在一起,但是bottle在处理时是单个url编码来看的,因此可用的变体字符极大受限,详情参考LamentXu的《聊聊bottle框架中由斜体字引发的模板注入(SSTI)waf bypass》:https://www.cnblogs.com/LAMENTXU/articles/18805019
| 原始字符 | 变体字符 |
|---|---|
| a | %aa |
| o | %ba |
json.loads
json.loads会解析unicode格式的数据,如果在json.loads解析前进行waf检测,可使用unicode绕过,例如:
1 | import json |

参考文章