GPUを使って無線LANをクラックする話・読解編・序

<VTuber語法>
はいどうもー!実在する架空のバーチャルプログラマの平藤でーす。のじゃのじゃー。

世の中世知辛い!

あの〜本職(?)が〜データセンターおじさんなんですけど〜、
無線LANのクラックやってもお金にならない!やってもあんま意味ない!(社会的に)

え〜そんなことよりも〜はるかに〜あの〜
データセンターとかで〜
重いサーバー二人掛かりで設置して腰痛めたり〜
あの〜機嫌が悪いとパケットが通らなくなるルータくんの設定したり〜
あのぉ…新しいサーバくんにSSDを刺したのに無視されたりすることなどのほうがぁ…
人生においてぇ…重要なことぉ…なのじゃあ。のじゃーーーーー。
</VTuber語法>

えー、何の話でしたっけ。あーそうだ。WPA2のパスワードをクラックしてたんでした。今回はその続きをやって、実際にクラックするのを目指していきましょう。

写真は今年の紫陽花です。

そういえば。そろそろ紫陽花の季節も終わりですけど、紫陽花の「花」って散らないんですよ。知ってました?…「フォトジェニック」ではないからか、街中の紫陽花の「花」はだいたい人間に刈り取られちゃうんですが。せちがらーーーーい!

WPA2でこっそり堂々と鍵を交換するヒミツ:有料

前回がんばってスマホが無線LANルータと「つながる」までのパケットをキャプチャーしたんでしたね。

今回は、このパケットのうち、「認証」をするために送りあっているKeyメッセージの部分をWPA2の仕様に沿ってもう少し詳しく読み解きましょう。

…うーん…。

…うーん…??

よーし、パパ気の効いた解説でも書いちゃうぞー、と思っていたのですが、ネット上に参考になる記事がありません。しょうがないにゃあ・・、(元の仕様書読むから)いいよ。

…と思ったところ、WPA2はIEEE802.11iという規格が元になっているらしいというところまでは突き止めたですが、IEEE802.11iの仕様書はなんと有料らしいのです。あと最近出たWPA3の仕様書ダウンロードフォームを発見したのですが(WPA2はどこだよ??)、なんか簡単にダウンロードできる気配じゃない!

世の中WiFiデバイスばっかなのに仕様書が地味にどこにあるのかわからない!あっても有料!

世の中、世知辛いのじゃー!

秘伝のソースからWPA2を理解するプログラマーの会

…えー。というわけで、作戦を変更して、逆にPyritのソースコードを解読してWPA2の「パスワード認証」に迫っていこうと思います。

えっ、そんな場当たり的でいいの?なんて声が聞こえてきたような気がしましたが、いいんです。無線LANのハックそのものが目的ではないですし(「目的」は強いて言うならGPUと遊ぶベンチマーク取る事だったんだよなぁ)、Pyritのソースで遊んでいるわけですから、WPA2の仕様書よりPyritのソースと仲良くなる方がきっとこの先楽しいと思います。たぶん。

雑に状況を要約:ゼアイズパスワードオンリー、合言葉しかない

こうなると我々には頼るべきものはもう何もありません。我々に残されたのは、パケットダンプとPyritのソース、そしてWiFiデバイスのみ。…まぁ、よくあるシチュエーションだなぁ。

無線LANルータに接続するために必要な情報は、WPA2-PSKを使っている場合、パスワードだけです。それ以外には正確に何もいりません。これが漏れるとオシマイです。FreeWiFiになります。

何を今更、当たり前なことを何度も繰り返しやがって、と思われるかもしれませんが、これはもう一度じっくり観察しておいてもよい事実だと思います。

WiFiの仕様を決めているWiFiアライアンスはいわゆるライセンス商法をやっておりまして、WiFiアライアンスの言う通りに製品を作って、WiFiアライアンスの試験に合格しないと「WiFi」は名乗れないようになっています。そうやって縛ることで互換性を高めて「とりあえず『WiFi』ってシール貼ってあるやつ買えば無線でネット繋がるんでしょ?」って消費者に安心して買ってもらおうとしてるわけですね。USBもまぁだいたい似たような感じなんですが、USBは最近USB Type-C™ Authenticationというのが入りまして、USBでお互いに通信する前に、相手の機械が本当にUSBの業界団体から認証をもらっているのかどうかのチェックをする事が可能になったらしいのです。今までは「USBのロゴを使ってよい」という商標の仕組みを使って許可してれば十分だったのが、それを守らない人が出てきたからそうなったんでしょうね〜。

さて。もし仮に、WiFiにもUSB Authenticationのような仕組みが導入されていた場合、Pyritはクラックする時に、パスワードだけでなく、WiFiアライアンスからもらった証明書も考慮してクラックしないといけなかったりするかもしれません(そうじゃないかもしれません)。そういうややこしい仕組みはない、パスワードだけ考えればいい、そういう事を確認したかったのです。なんで仕様書もソースも読まずにそんな事を断言できるのかというと、…WiFiモジュールの一切入っていないGPUサーバでPyritが動いてクラックできているからです。えぇ、我々にはもはや…こんな推論しか、できる事はないのですよ。

言い換えると、WPA2-PSKで認証できたかどうかのチェックをする関数は、次のような関数シグネチャに集約される事が予想されます:

// あくまで予想。
bool checkWPA2Password(byte[] captureData, string password);

うーん、とってもかんたん。もちろん、パケットキャプチャデータの中からどのデータをどう使うかが大事なわけですし、もっと言えば並列化してるのでもっともっと複雑になっていて、関数1つに纏まっているはずはないと思います。が、こういうのを多少なりとも頭に浮かべながら読むのって大事だと思うんよ。

とりあえず正解のデータを突っ込んで「解けた」と言ってもらう

Pyritのソースもそれなりにデカいので、まず一旦「解けた」と言うコードパスを走ってもらいましょう。言い換えると、正解を突っ込んで「クラックできました!」とPyritに叫んでもらいましょう。マッチポンプ…はちょっと違うか。

えーと。パスワードの正解をズバリ言ってしまうと「xxxxxxxxx」です。今すぐダウンロー
。データセンターに置いてあるWiFiのパスワードもこれです。

(ブログを検閲する偉い人によって文章の一部が改変されました)

…あーっ、ちょっと、悪用しないでくださいよ。まぁ「おらけんま」ができなくなった今、わざわざオランダヒルズの面倒な警備を乗り越えてオフィスにFreeWiFiしにやってくる輩も居ないだろうと思って書いてるわけですが。FreeWiFiなんて今日び、駅からファミレスからコンビニ、スタバまで、そこらじゅうにあるからね。…あとは隣のオフィスの人がこれを読んで無いのを祈るだけだな。

さて。この正解のパスワードをPyritに教えて「解けた!!!」って叫んでもらうには次の呪文を唱えればよいです(zoi.capはリポジトリに入っています):

~/src/Pyrit$ pyrit -r zoi.cap -i passwords.txt -b '00:1a:eb:ac:51:30' --all-handshakes attack_passthrough
Pyrit 0.5.1 (C) 2008-2011 Lukas Lueg - 2015 John Mora
https://github.com/JPaulMora/Pyrit
This code is distributed under the GNU General Public License v3+

Parsing file 'zoi.cap' (1/1)...
Parsed 4055 packets (4055 802.11-packets), got 104 AP(s)

Attacking 4 handshake(s).
Tried 1 PMKs so far; 0 PMKs per second. xxxxxxxxx

The password is 'xxxxxxxxx'.

passwords.txtにパスワードの羅列が書いてあります。今回は正解の1行だけを入力しました:

$ cat passwords.txt
xxxxxxxxx

しかし。そんな雑なパスワードでいいのかよ!という声がいかにも聞こえてきそうですが、Pyritも使っている辞書攻撃はだいたい英語を前提にしているので、ローマ字の日本語と英語を混ぜたこのパスワードは意外と当てられないんじゃ無いかなぁというのが個人的な見解です。

Pyritがデフォルトで持ってる辞書がtest/dict.gz以下にあるのですが、例えばそれでは破れません:

$ pyrit -r zoi.cap -i test/dict.gz -b '00:1a:eb:ac:51:30' --all-handshakes attack_passthrough
Pyrit 0.5.1 (C) 2008-2011 Lukas Lueg - 2015 John Mora
https://github.com/JPaulMora/Pyrit
This code is distributed under the GNU General Public License v3+

Parsing file 'zoi.cap' (1/1)...
Parsed 4055 packets (4055 802.11-packets), got 104 AP(s)

Attacking 4 handshake(s).
Tried 4094 PMKs so far; 1552 PMKs per second. abrogate

Password was not found.

あとWiFiって近くに居ないと繋げないですから、攻撃を受ける回数はネット越しで攻撃できるsshと比べれば段違いに少ないはずですし、「ネットをつなぐための鍵」なのでできるだけ覚えやすい方が嬉しいかなぁと思ってこれに設定してます。スマホの暗証番号が4桁なのとだいたい同じ理論武装です。

嘘です。ジャバがすき(きらい)だからで、残りは全部後付け。Javaとは何で、なぜ必要ですか。

…はい。

WiFiクラックって(今まさにやってるような)スクリプトキディ的ハッキングの題材としてはありがちで、ネット上でも雑誌上でもよく見るから、みんな好きなんでしょうけど(Pyritクラスター作ってるやつらもおるぞ)、「WiFiのパスワードってそこまでクラックする価値あるの?sshとかTLSの穴探した方が『コスパ』いいんじゃない?」みたいなのが正直な感想ですな。己の記事全否定か〜?って感じですが、いや、まぁ、今回やってるのはベンチマークであって、そういう「価値」とはまた違う話ですから。…それに、公開IPにやってくる大量のsshログイン試行ログを見ていると、明らかに「業務」「ビジネス」としてやってる感じが見て取れて、それはそれで、気が滅入るのですよ。その点、Pyritクラスターは見てるだけでも楽しそうでしょ?

ソースコードの海に飛び込むのです

だいぶ話が逸れました。Pyritがパスワードを発見するのに成功した場合と失敗した場合のメッセージを得たので、これを足がかりにソースコードを読解していきます。Pyritもそれなりに大きいので、全部読むのは正直大変だなーという判断です。

するとサクッとヒットします。二箇所あるんですが、コマンド名と関数が同じこちらでしょう。

    def attack_passthrough(self, infile, capturefile, essid=None, \
                           bssid=None, outfile=None, all_handshakes=False, \
                           use_aes=False):

# ...

        if not all_handshakes:
            crackers.append(cpyrit.pckttools.AuthCracker(auths[0], use_aes))
        else:
            self.tell("Attacking %i handshake(s)." % (len(auths),))
            for auth in auths:
                crackers.append(cpyrit.pckttools.AuthCracker(auth, use_aes))
        with cpyrit.util.FileWrapper(infile) as reader:
            with cpyrit.cpyrit.PassthroughIterator(essid, reader) as rstiter:
                for results in rstiter:
                    for cracker in crackers:
                        cracker.enqueue(results)
# ...
        for cracker in crackers:
            cracker.join()
            if cracker.solution is not None:
                self.tell("\nThe password is '%s'.\n" % cracker.solution)
                if outfile is not None:
                    with cpyrit.util.FileWrapper(outfile, 'w') as writer:
                        writer.write(cracker.solution)
                break
        else:
            errmsg = "\nPassword was not found."

これの上の方を手繰っていくと、cpyrit.pckttools.AuthCrackerというのがクラッカーをまとめてる親玉らしいことがわかります:

class AuthCracker(object):

    def __init__(self, authentication, use_aes=False):
        self.queue = Queue.Queue(10)
        self.workers = []
        self.solution = None
        if authentication.version == "HMAC_SHA1_AES" \
         and authentication.ccmpframe is not None \
         and use_aes:
            self.cracker = CCMPCrackerThread
        else:
            self.cracker = EAPOLCrackerThread
        for i in xrange(util.ncpus):
            self.workers.append(self.cracker(self.queue, authentication))

    def _getSolution(self):
        if self.solution is None:
            for worker in self.workers:
                if worker.solution is not None:
                    self.solution = worker.solution
                    break

    def enqueue(self, results):
        self.queue.put(results)
        self._getSolution()

    def join(self):
        self.queue.join()
        for worker in self.workers:
            worker.shallStop = True
        self._getSolution()

早速CCMPCrackerThreadとEAPOLCrackerThreadという二種類がいることがわかるわけですが、これをprintfデバッグしてどっちなのか確かめると(こういうのを何の躊躇いもなく「さっ」とやるために前回わざわざ自前でビルドしたんやぞ)、EAPOLCrackerThreadを使っているらしいです。が、–aesってオプションつけてやるとCCMPCrackerThreadを使うようになります。でも、どっちでもちゃんとパスワードを解読できます。…ど、どういう事じゃ…?

ちなみに実装はすぐ上にありまして…

class CrackerThread(threading.Thread):

    def __init__(self, workqueue):
        threading.Thread.__init__(self)
        self.workqueue = workqueue
        self.shallStop = False
        self.solution = None
        self.numSolved = 0
        self.setDaemon(True)
        self.start()

    def run(self):
        while not self.shallStop:
            try:
                results = self.workqueue.get(block=True, timeout=0.5)
            except Queue.Empty:
                pass
            else:
                solution = self.solve(results)
                self.numSolved += len(results)
                if solution:
                    self.solution = solution[0]
                self.workqueue.task_done()


class EAPOLCrackerThread(CrackerThread, _cpyrit_cpu.EAPOLCracker):

    def __init__(self, workqueue, auth):
        CrackerThread.__init__(self, workqueue)
        _cpyrit_cpu.EAPOLCracker.__init__(self, auth.version, auth.pke,
                                          auth.keymic, auth.keymic_frame)


class CCMPCrackerThread(CrackerThread, _cpyrit_cpu.CCMPCracker):

    def __init__(self, workqueue, auth):
        if auth.version != "HMAC_SHA1_AES":
            raise RuntimeError("CCMP-Attack is only possible for " \
                               "HMAC_SHA1_AES-authentications.")
        CrackerThread.__init__(self, workqueue)
        s = str(auth.ccmpframe[scapy.layers.dot11.Dot11WEP])
        msg = s[8:8+6]
        counter = (s[0:2] + s[4:8])[::-1]
        mac = scapy.utils.mac2str(auth.ccmpframe.addr2)
        _cpyrit_cpu.CCMPCracker.__init__(self, auth.pke, msg, mac, counter)

_cpyrit_cpuパッケージは、C言語で書かれたモジュールです。あれっ、CUDAは?まぁいいや。とりあえずここでPythonの外に出る事が確認できました。そして、initに渡されている情報が、それぞれのCrackerで必要な情報らしいことがわかります(そしてそれはEAPoLとCCMPで違うこともわかります)。

それぞれのCrackerはsolve()という(C言語で書かれた)メソッドを持ってて、ここへresultsというのが降ってくるのがわかりますが、このresultsというのは具体的に何なのだろう…と思って”The password is “のところまで戻ってもっかい手繰っていくと、cpyrit.cpyrit.PassThroughIteratorというところへ行き着き、これはさらにその名もCPyritというクラスに処理を投げていることが見て取れます。…そして、こちらにはCUDAとかなんとか書いてありますね。

class CPyrit(object):
    """Enumerates and manages all available hardware resources provided in
       the module and does most of the scheduling-magic.
       The class provides FIFO-scheduling of workunits towards the 'host'
       which can use .enqueue() and corresponding calls to .dequeue().
       Scheduling towards the hardware is provided by _gather(), _scatter() and
       _revoke().
    """

    def __init__(self):
        """Create a new instance that blocks calls to .enqueue() when more than
           the given amount of passwords are currently waiting to be scheduled
           to the hardware.
        """
        self.inqueue = []
        self.outqueue = {}
        self.workunits = []
        self.slices = {}
        self.in_idx = self.out_idx = 0
        self.cores = []
        self.CUDAs = []
        self.OpCL = []
        self.all = []
        self.cv = threading.Condition()

        # CUDA
        if config.cfg['use_CUDA'] == 'true' and 'cpyrit._cpyrit_cuda' in sys.modules and config.cfg['use_OpenCL'] == 'false':

            CUDA = _cpyrit_cuda.listDevices()

            for dev_idx, device in enumerate(CUDA):
                self.CUDAs.append(CUDACore(queue=self, dev_idx=dev_idx))

# ...

        # CPUs
        for i in xrange(util.ncpus):
            self.cores.append(CPUCore(queue=self))

# ...

むっ、じゃあさっきのCrackerThreadっていういかにも「クラックしてます!!!!!」的な空気を纏わせてた連中はCUDAでは走らないってこと?

・・・今日のところは、この辺で勘弁しといてやりますか・・・。

ソース読解のまとめ

まぁとりあえず。CrackerとIteratorの2つの「重い処理」があって、IteratorはCUDAでも処理されているが、CrackerはCPUにしか移譲されてなさそうな所は見えてきました。そしてパスワードのリストを読んでIteratorが作ったresultsをCrackerがsolveして「パスワードあたり!」って叫んだり叫ばなかったりすることも。

これはすごい雑な仮説ですが、もしCUDAのカードをいっぱいぶち込んで5000兆フロップス並列に処理できたとしても、Iteratorは速くなるけど、CPUで動くCrackerがボトルネックになってGPUが遊んでしまうでしょう。すると、Teslaでも大して速くなかった前回の結果を擁護できるかもしれません。Teslaくんは悪く無い、CPUで動いてるCrackerが遅いのが悪いんだ!って。ありそうなボトルネックとしてテキストファイルを読む所や、GPUへパスワードを運ぶメモリ帯域を想像していたんですが、実際にソースを読む事でもう一つ見つけられたわけです。もちろん、ベンチマークとか取ったり実験してみないと、どこがボトルネックかなんてわからんわけですが。

<VTuber語法>
よくわかん事、Pyrit…CUDA…よくわからん…のじゃー(突然戻ってくる狐)

…でも、でもでもでも!
こうやって、こう ちょっとつ、ステップアップする所のぉー

この…CUDAとかC言語とPythonが組み合わさったやつのプロファイルもどうやって取ればいいのかわからないし。
ほんとはこう…いろいろやりたいけど。
(まだ)できないっ!

世知辛い!
世の中世知辛い!
世の中世知辛いのじゃーーー!!!!のじゃー!
ふーんふーんふーん(元ネタを知らないがとりあえず踊っておく狐)
</VTuber語法>

プロファイルの取り方を調べるのは次回までの宿題にしておきます。

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Google フォト

Google アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中