TL;DR
- Pythonは変数・関数を全角文字やその他さまざまな文字で宣言できる
-
Pythonは変数や関数名を正規化してから解釈する e.g.
foo == foo # True
- evalなどでユーザの入力に従いPythonのコードが実行可能な場合で、入力値の制限が十分でない場合と悪用が成立するかもしれない
きっかけ
Pearl CTFというCTFに "b4by_jail" という問題がありました。問題はPythonのソースファイルが添付されていて、netcatでつないでやり取りするという形式でした。
問題のコードは以下のようなものです。
#!/usr/local/bin/python
flag="pearl{f4k3_fl4g}"
blacklist=list("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ~`![]{},<>/123456789")
def check_blocklist(string):
for i in string:
if i in blacklist:
return(0)
return(1)
def main():
cmd=input(">>> ")
if(check_blocklist(cmd)):
try:
print(eval(cmd))
except:
print("Sorry no valid output to show.")
else:
print("Your sentence has been increased by 2 years for attempted escape.")
main()
入力として"flag"という文字列を与えればeval(cmd)
が評価されてflag
変数を参照できそうですが、[a-zA-Z~
![]{},<>/1-9]`が含まれる場合は拒否されてしまうようです。
最初はblacklist
に含まれない[0()%+-]
などをうまく使って jsfuck のようなことをできないかなと調べて pyjsfuck というプロジェクトのREADME.md
を読み込んでいたのですが、どうもこの文字セットだけで文字列を表現するのは難しそうでした。
しばらく悩んでいるとふと、asciiの印刷可能文字に限定して考える必要ないのではないかと思いました。そう言えば Python って変数名にascii以外も使えた気がするなと思い、Pythonインタプリタを開き全角文字を使い色々試していると、以下のような動作が確認できました。
>>> b= 'b'
>>> b
'b'
>>> print('hi')
hi
半角のbで宣言した変数b
を全角の"b" で参照できています。また、全角文字で関数 print()
を呼び出せています。
ならばとこの問題に全角で"flag"と入力し、フラグを取得することができました。
検証
どの文字が使えるかということに関しては既存の記事があったのでそれを参照することをおすすめします。
全角文字以外にも同一の文字として解釈される文字がありそうだなということで総当りで試すコードを書きました。
import unicodedata
import csv
def check_normalization_equal(c1, c2):
define = f"a{c1} = 'a'"
check = f"a{c2}"
result = False
try:
exec(define)
result = (eval(check) == 'a')
except:
pass
return result
def main():
utf8 = []
for i in range(0, 0x10FFFF):
try:
utf8 += [chr(i)]
except:
pass
alphameric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"
f = open("./python-bypass-ascii.csv", 'w')
writer = csv.writer(f)
writer.writerow(['ASCII', 'UTF-8', 'Codepoint','Unicode Name'])
for an in alphameric:
for u8 in utf8:
if check_normalization_equal(an, u8):
print(an, u8, unicodedata.name(u8))
writer.writerow([an, u8, f'U+{ord(u8):02X}', unicodedata.name(u8)])
f.close()
if __name__ == '__main__':
main()
main()
とcheck_normalization_equal()
では以下のような処理をしています。
-
[a-zA-Z0-9_]
とUTF-8の全文字の組み合わせを列挙する -
exec()
でaa
,a0
,a_
などの変数をそれぞれ宣言 -
eval('aª')
,eval('a0')
,eval(a﹏)
などを評価 - 前者と後者の値が一致すれば出力を行う
a0
のような形で2文字目になっているのは、2文字目以降しか使えない文字 [0-9]
が存在するからです。
結果
結果は以下のようになりました。以下のような文字が同一のものと解釈されるようです。
ASCII | 対応するUnicodeの文字名(一部省略) | 例 |
---|---|---|
A-Za-z0-9_ | FULLWIDTH | Aa0_ |
A-Za-z0-9 | MATHEMATICAL BOLD | 𝐀𝐚𝟎 |
A-Za-z0-9 | MATHEMATICAL DOUBLE-STRUCK | 𝕒𝔸𝟘 |
A-Za-z0-9 | MATHEMATICAL SANS-SERIF | 𝖠𝖺𝟢 |
A-Za-z0-9 | MATHEMATICAL SANS-SERIF BOLD | 𝗮𝗔𝟬 |
A-Za-z0-9 | MATHEMATICAL MONOSPACE | 𝚊𝙰𝟶 |
A-Za-z | MATHEMATICAL ITALIC | 𝑎𝐴 |
A-Za-z | MATHEMATICAL BLOD ITALIC | 𝒂𝑨 |
A-Za-z | MATHEMATICAL SCRIPT | 𝒶𝒜 |
A-Za-z | MATHEMATICAL BOLD SCRIPT | 𝓪𝓐 |
A-Za-z | MATHEMATICAL FRAKTUR | 𝔞𝔄 |
A-Za-z | MATHEMATICAL SANS-SERIF ITALIC | 𝘢𝘈 |
A-Za-z | MATHEMATICAL SANS-SERIF BOLD ITALIC | 𝙖𝘼 |
A-RT-Wa-rt-z | MODIFIER LETTER | ᵃᴬ |
aehijklmnoprtuvx | SUBSCRIPT SMALL | ₐₑₕᵢⱼₖₗₘₙₒₚᵣₜᵤᵥₓ |
cdilmvxCDILMVX | ROMAN NUMERIC | ⅽⅾⅰⅼⅿⅴⅹⅭⅮⅠⅬⅯⅤⅩ |
BEFHILMReglo | SCRIPT CAPITAL/SMALL | ℬℰℱℋℐℒℳℛℯℊℓℴ |
deijCDHNPQRZ | DOUBLE-STRUCK CAPITAL | ⅆⅇⅈⅉℂⅅℍℕℙℚℝℤ |
0-9 | SEGMENTED DIGIT | 🯰🯱🯲🯳🯴🯵🯶🯷🯸🯹 |
CHIRZ | BLACL-LETTER | ℭℌℑℜℨ |
in | SUPERSCRIPT | ⁱⁿ |
ao | FEMININE/MASCULINE ORDINAL INDICATOR | ªº |
i | INFORMATION SOURCE | ℹ |
K | KELVIN SIGN | K |
h | PLANCK CONSTANT | ℎ |
_ | PRESENTATION FORM FOR VERTICAL LOW LINE | ︳ |
_ | PRESENTATION FORM FOR VERTICAL WAVY LOW LINE | ︴ |
_ | DASHED LOW LINE | ﹍ |
_ | CENTRELINE LOW LINE | ﹎ |
_ | WAVY LOW LINE | ﹏ |
数学関連のものが多く、他にも7セグメント数字や一部の特殊な記号などが含まれています。アンダーバー"_"と縦棒"︳"が同一の文字として解釈されるのは面白いですね。
これがどのような手順で正規化されたかは私の知識だとよくわからないです。
unicodedata.normalize() で使用可能な方法で正規化してみましたが、結果に丸数字①が含まれるなど、同じ結果にはなりませんでした。
まとめ
PythonはUnicode文字を変数名として命名すると、正規化してから解釈されるようです。この正規化によって変数・関数に命名可能な文字[a-zA-Z0-9_]と同等と解釈されるものには、全角文字のほか数学関連の文字や一部の特殊な文字などが含まれているようでした。
eval()などが使われていると、一見安全にフィルタしている様に見えてもこのような工夫によってバイパスできてしまうことがあります。evalのような関数はどの言語でも基本使わないようにしましょう。