GPUを使って無線LANをクラックする話・ボトルネック発見編

データセンターで偶然出会った、キーボード・No.1さんです。ナンバーワンですって。

まさか、こんなところでキーボード界の頂点に出会えるとは。偶然てのは、すごいですね。

キーボードを見る目のないわたしには、この方を見てもその凄さ−《王者の風格》、とでも言うべきものでしょうか−そういったものは、感じ取れませんでした。「能ある鷹は爪を隠す」ってやつなんでしょうか。いやぁ、御見逸れ致しました。わたくしにはキーボードの才能が無いのでしょう。わたしはせいぜい、指でキーボードぺちぺちする身で一生甘んじていようと思います。

キーボードのNo.1ってどうやって決めるんだろうとか、次の世界大会はいつなのかとか、聞けば良かったな。サインももらえば良かったかも。

…えー、もちろん嘘で、ただの通し番号です。データセンターにはたくさん共用のキーボードがあって空いてるものを番号の小さいものから貸してもらえるんですが、同時に作業する人は居ないのでいつもだいたいこのキーボードを貸してもらって作業しています。でも不思議かな、みんなこのキーボードばっかり使ってるはずなのに、「キーボードNo.10」とかの方がボロボロな感じがします。…もしかして、やっぱ世界1位《ザ・トップ・オブ・キーボード》だったのか?

いや…まさかね。

前回までのPyritなのですよ

えー、何の話でしたっけ。あーそうそう。無線LANのパスワード認証方式WPA2-PSKをPyritでクラックしてたんだけど、WPA2の仕様書が読めないから諦めて直接Pyritのソースを読んでいたんでしたね。

えー、何の話でしたっけ。あーそうそう。無線LANのパスワード認証方式WPA2-PSKをPyritでクラックしてたんだけど、WPA2の仕様書が読めないから諦めて直接Pyritのソースを読んでいたんでしたね。

簡単におさらいです:

  • IteratorCrackerというのがいて
  • 辞書ファイルから読んだパスワードのリストを、Iteratorがresultsにして
  • Crackerがresultsを検証して「そのパスワード、あたりっ!」「はずれっ!」と判定する
  • CUDAを使っているのはIteratorだけ。CrackerはCPUでしか動かない。

というのを前回ソース読んで確認したんでした。

そもそも何を測っているのかを把握するのですよ

太古の昔に書いた最初の記事では、Pyritの「benchmark」というコマンドを実行してCPUと比較してみたり、GPU同士で比較して「Tesla V100はGTX1080よりめっちゃ高くて性能いいはずなのに大差ない…」とか嘆いていたわけですが、そもそもこれは何を測っていたのでしょうか。前回多少なりともソースコードを読んだ事により、このような多少深まった疑問が生まれるわけですな。前回見た限り、Crackerも二種類あって、データやコマンドラインフラグによって変わるんですよね。その辺の条件は?

…というわけで、早速読んでみましょう。pyritコマンドのサブコマンドの実装は全部ルートにあるpyrit_cli.pyに置いてありまして、コマンド名と関数名はすべて一致しています。つまり、benchmarkコマンドもbenchmark関数に置いてあります。大変わかりやすい。

ちょっと長いですが、多少はしょりつつ載せます:

    def benchmark(self, timeout=45, calibrate=10):
# ... 略 ...
# ダミーの仕事をなげまくる仕事
            t = time.time()
            perfcounter = cpyrit.util.PerformanceCounter(timeout + 5)
            while time.time() - t < timeout:
                pws = ["barbarbar%s" % random.random() for i in xrange(bsize)]
                cp.enqueue('foo', pws)
                r = cp.dequeue(block=False)
# ... 略 ...
# CPUの結果を表示する
            for i, core in enumerate(cp.cores):
                if core.compTime > 0:
                    perf = core.resCount / core.compTime
                else:
                    perf = 0
                if core.callCount > 0 and perf > 0:
                    rtt = (core.resCount / core.callCount) / perf
                else:
                    rtt = 0
                self.tell("#%i: '%s': %.1f PMKs/s (RTT %.1f)" % \
                            (i + 1, core.name, perf, rtt))
# ... 略 ...
# GPUの結果を表示する
            for i, CD in enumerate(cp.CUDAs):
                if CD.compTime > 0:
                    perf = CD.resCount / CD.compTime
                else:
                    perf = 0
                if CD.callCount > 0 and perf > 0:
                    rtt = (CD.resCount / CD.callCount) / perf
                else:
                    rtt = 0
                self.tell("#%i: '%s': %.1f PMKs/s (RTT %.1f)" % \
                          (i + 1, CD.name, perf, rtt))
# ... 略 ...

cpyrit.cpyrit.CPyrit() as cp というのはIteratorが依存している、パスワードを入れるとresultsというリストが出て来るモジュールでした(で、これをCrackerがチェックする)。

すぐに気がつくのは、CPUやCUDAごとにベンチマークしてるわけではないことです。cp.enqueueとして「パスワード計算してくれや」とお願いすると、cpくんが代理店となって、実際にCPU、CUDA(、OpenCL)のどれで計算するかを決めるようになっています。で、あとで集計するときにcpくんに「ところでTesla使ったのってどれくらい?」って聞いて、その結果をベンチの結果としてまとめる、と。

うーん。APIとしてはバックエンドが隠蔽されてて、いい感じだと思います。でも、これで本当に「公平」にベンチマークできているのかは、若干怪しい感じがするんですが…。

とりあえず。前回、「CPUだけで走るCrackerがボトルネックとなってbenchmarkでTeslaがあんまり速くなかったのでは?」という仮説を立てましたが、それは否定されました。だって、見ての通り、Crackerは走らないからです。もちろん、「このbenchmarkでは」であって、実際にパスワードを検索する時はCrackerも走らせて正しいパスワードかどうかチェックするので、Crackerがボトルネックとなる可能性は残ります。

綺麗なCUDAプロファイラーでも眺めるのですよ

なんか手詰まり感があるので、ここで一旦ソースコードの調査を打ち切ってプロファイラーで遊んでみましょう。

というのも。

ベンチマーク取ってる間、nvidia-smiコマンドを打つとこんな感じで消費電力が見れるわけですが:

+-----------------------------------------------------------------------------+
| NVIDIA-SMI 390.30                 Driver Version: 390.30                    |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|===============================+======================+======================|
|   0  Tesla V100-PCIE...  Off  | 00000000:3D:00.0 Off |                    0 |
| N/A   55C    P0    43W / 250W |      0MiB / 16160MiB |      0%      Default |
+-------------------------------+----------------------+----------------------+
|   1  Tesla V100-PCIE...  Off  | 00000000:3E:00.0 Off |                    0 |
| N/A   57C    P0    45W / 250W |      0MiB / 16160MiB |      0%      Default |
+-------------------------------+----------------------+----------------------+
|   2  GeForce GTX 108...  Off  | 00000000:B1:00.0 Off |                  N/A |
| 28%   50C    P0    60W / 250W |      0MiB / 11178MiB |      0%      Default |
+-------------------------------+----------------------+----------------------+
|   3  GeForce GTX 108...  Off  | 00000000:B2:00.0 Off |                  N/A |
| 32%   56C    P0    57W / 250W |      0MiB / 11178MiB |      2%      Default |
+-------------------------------+----------------------+----------------------+

+-----------------------------------------------------------------------------+
| Processes:                                                       GPU Memory |
|  GPU       PID   Type   Process name                             Usage      |
|=============================================================================|
|  No running processes found                                                 |
+-----------------------------------------------------------------------------+

pyrit benchmarkを実行している間も、このアイドル時の画面と正直消費電力があんまり変わらなくて、ごくごくたまにGPUのどれか1つがちょっと電気を使うようになって、また戻る…そんな感じなんですよ。

+-----------------------------------------------------------------------------+
| NVIDIA-SMI 390.30                 Driver Version: 390.30                    |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|===============================+======================+======================|
|   0  Tesla V100-PCIE...  Off  | 00000000:3D:00.0 Off |                    0 |
| N/A   54C    P0    48W / 250W |    427MiB / 16160MiB |      0%      Default |
+-------------------------------+----------------------+----------------------+
|   1  Tesla V100-PCIE...  Off  | 00000000:3E:00.0 Off |                    0 |
| N/A   56C    P0    51W / 250W |    427MiB / 16160MiB |      0%      Default |
+-------------------------------+----------------------+----------------------+
|   2  GeForce GTX 108...  Off  | 00000000:B1:00.0 Off |                  N/A |
| 44%   74C    P2   198W / 250W |    167MiB / 11178MiB |     36%      Default | ←一時的に使用率が上がった(でもたった36%??)
+-------------------------------+----------------------+----------------------+
|   3  GeForce GTX 108...  Off  | 00000000:B2:00.0 Off |                  N/A |
| 48%   79C    P2    85W / 250W |    161MiB / 11178MiB |      0%      Default |
+-------------------------------+----------------------+----------------------+

+-----------------------------------------------------------------------------+
| Processes:                                                       GPU Memory |
|  GPU       PID   Type   Process name                             Usage      |
|=============================================================================|
|    0     96620      C   /home/psi/src/Pyrit/.env/bin/python          416MiB |
|    1     96620      C   /home/psi/src/Pyrit/.env/bin/python          416MiB |
|    2     96620      C   /home/psi/src/Pyrit/.env/bin/python          157MiB |
|    3     96620      C   /home/psi/src/Pyrit/.env/bin/python          151MiB |
+-----------------------------------------------------------------------------+

CPU(htop)もこんな感じで全コア使い切ってない感が否めない…。

でも、それっておかしいですよねぇ?上で読んだように、CPUやGPUごとじゃあなくて、全部に対して仕事を投げまくってるので、理想的な状況としては全GPUと全CPUがTDPギリギリに張り付いてないとおかしいはずなんです。で、せっかく静かにしたファンが全力で回るようになると。ちなみにわたくしリモートで勤務するバーチャル実在する架空のプログラマですので、音のほうは確かめられません。フヒヒ。

というわけで、まずは本当にGPUが遊んでいるのかどうか、プロファイラーで調べられないか試してみます。

PythonとC言語とCUDAが組み合わさった現代のバベルの塔としか言いようがないこの複雑極まりないシステムですが、DeepLearningで非常によくある組み合わせだからかツール自体はかなり整備されておりまして(そしてDeepLearningの場合はさらに統計的機械学習の難しい理論や巨大なデータがその塔の上に積み上がっていく)、こんな感じで非常に簡単にプロファイルできるようになっています:

# nvidia-profilerは別パッケージらしい
sudo apt install nvidia-profiler

# 測定したいコマンドの前にnvprof -o <出力>.nvvpをつけるだけ。すっごいかんたん!
nvprof -o profile.nvvp pyrit benchmark_long

しかもこのprofile.nvpの結果を見るためのVisual Profilerは、なんとmacでも動きます!リモートのLinuxでももちろん動きます!GPUがあってもなくても!…資本主義ってこえぇなぁ。

nVIDIAのサイトからCUDAのmac版をインストールして、DriverとToolkitとSampleのうち、Toolkitだけインストールします。DriverもインストールできるってことはGeForce積んでるmacもあるんだな…。

で、/Developer/NVIDIA/CUDA-9.2/bin/nvvp (macの場合。CUDAバージョンによって違う)を実行して、openからscpか何かで持ってきたprofile.nvvpを開きます。結果はこんな感じになりましたとさ:

めっちゃスカスカやんけー!

この茶色とか青い棒が出てるところが「計算したで」「CPUとGPUの間でデータのやりとりしたで」を表しているので、このスカスカ具合は、…要するに「GPU全然使えてません」ということになります。つまり、nvidia-smiから雑に考えた仮説は正しかったってこと。

もうすこし拡大して見て行くと、だいたい1秒に一回ぐらいGPUが叩き起こされて、0.06〜0.08秒だけ起こされてまた寝てます。正直ちょっと羨ましい!…いや、そんな頻繁にちょっとだけ起こされるのもやだな。

最初、GPUとCPUの間でパスワードのデータをやりとりするところがボトルネックになるのかなぁ、と予想したりもしていましたが、それは否定されました。計算している80ミリ秒とかに対して、データの転送は0.1ミリ秒程度です。誤差。

ベンチというのは分母と分子が大事なのですよ

まだあわてるような時間じゃない

仮にほとんどGPUを使っていないとしても、ベンチマークの値の信憑性にただちに影響があるわけではありません。

元のソースコードに戻りましょう:

            for i, CD in enumerate(cp.CUDAs):
                if CD.compTime > 0:
                    perf = CD.resCount / CD.compTime
                else:
                    perf = 0

「ベンチマーク」と英語で横文字を使ってみるとなんとなく知的でイケてる感じですが、要するに「仕事率」を測っておるわけです。「やった仕事」を「掛かった時間」で割ってるだけ。

この「掛かった時間」が実際にはどこからどこまでなのかが問題なわけです。

仮に、GPUが1秒間に80ミリ秒しか仕事してないとしても、「掛かった時間」も80ミリ秒なら、その「ベンチマーク」は「GPUの計算の仕事率」を測れていることになります。もし仮にそうなら、GTX1080とTeslaはパスワードクラックという仕事では、そんなに実力に差がないという事になるでしょうね。Pyritに対しては「もっとGPUに仕事割り当ててあげて」と言う事になります。

じゃあ、そうじゃなかったら?…それは、GPUを働かせるために必要な他の仕事の方にばかり時間を取られていて、GPUをうまく動かせていない、という結論が出てきます。例えていうなら、30分の大学院のミーティングに出るために2時間電車乗ってるような。…はぁ…。もし仮にそうなら、ベンチマークの結果はGPUそのものの性能差はあまり反映できていない、という事になるでしょうね。もっといえば、その「余分な仕事」はCPUを使ってるはずなので、このCPUとGPUを同時に走らせるベンチマークは、公平でないことになります。

ソースコードをちょっと書き換えて、この分母であるCD.compTime も表示するようにしましょう。

した結果がこんな感じ:

#1: 'CPU-Core (SSE2/AES)': 528.7 PMKs/s (RTT 2.7) compTime=24
#2: 'CPU-Core (SSE2/AES)': 585.8 PMKs/s (RTT 2.8) compTime=31
#3: 'CPU-Core (SSE2/AES)': 559.6 PMKs/s (RTT 2.9) compTime=35
#4: 'CPU-Core (SSE2/AES)': 537.1 PMKs/s (RTT 2.7) compTime=32
#5: 'CPU-Core (SSE2/AES)': 566.9 PMKs/s (RTT 3.0) compTime=27
#6: 'CPU-Core (SSE2/AES)': 488.4 PMKs/s (RTT 3.0) compTime=33
#7: 'CPU-Core (SSE2/AES)': 569.0 PMKs/s (RTT 3.1) compTime=31
#8: 'CPU-Core (SSE2/AES)': 506.9 PMKs/s (RTT 2.9) compTime=29
#9: 'CPU-Core (SSE2/AES)': 520.3 PMKs/s (RTT 2.8) compTime=19
#10: 'CPU-Core (SSE2/AES)': 579.7 PMKs/s (RTT 2.8) compTime=28
#11: 'CPU-Core (SSE2/AES)': 543.4 PMKs/s (RTT 3.1) compTime=31
#12: 'CPU-Core (SSE2/AES)': 580.9 PMKs/s (RTT 3.0) compTime=33
#13: 'CPU-Core (SSE2/AES)': 569.9 PMKs/s (RTT 2.9) compTime=37
#14: 'CPU-Core (SSE2/AES)': 496.3 PMKs/s (RTT 2.9) compTime=23
#15: 'CPU-Core (SSE2/AES)': 546.1 PMKs/s (RTT 2.8) compTime=34
#16: 'CPU-Core (SSE2/AES)': 564.5 PMKs/s (RTT 2.8) compTime=36
#17: 'CPU-Core (SSE2/AES)': 538.7 PMKs/s (RTT 2.9) compTime=31
#18: 'CPU-Core (SSE2/AES)': 621.0 PMKs/s (RTT 2.8) compTime=25
#19: 'CPU-Core (SSE2/AES)': 527.8 PMKs/s (RTT 2.8) compTime=31
#20: 'CPU-Core (SSE2/AES)': 492.7 PMKs/s (RTT 2.8) compTime=34
#21: 'CPU-Core (SSE2/AES)': 532.0 PMKs/s (RTT 2.8) compTime=28
#22: 'CPU-Core (SSE2/AES)': 558.4 PMKs/s (RTT 2.9) compTime=29
#23: 'CPU-Core (SSE2/AES)': 548.8 PMKs/s (RTT 2.8) compTime=31
#24: 'CPU-Core (SSE2/AES)': 528.6 PMKs/s (RTT 2.9) compTime=32
#25: 'CPU-Core (SSE2/AES)': 594.7 PMKs/s (RTT 2.8) compTime=28
#26: 'CPU-Core (SSE2/AES)': 508.1 PMKs/s (RTT 2.8) compTime=33
#27: 'CPU-Core (SSE2/AES)': 534.2 PMKs/s (RTT 2.9) compTime=32
#28: 'CPU-Core (SSE2/AES)': 545.9 PMKs/s (RTT 3.1) compTime=31
#29: 'CPU-Core (SSE2/AES)': 543.0 PMKs/s (RTT 3.0) compTime=39
#30: 'CPU-Core (SSE2/AES)': 550.1 PMKs/s (RTT 2.6) compTime=29
#31: 'CPU-Core (SSE2/AES)': 595.8 PMKs/s (RTT 3.1) compTime=31
#32: 'CPU-Core (SSE2/AES)': 581.9 PMKs/s (RTT 2.7) compTime=30
#33: 'CPU-Core (SSE2/AES)': 537.9 PMKs/s (RTT 2.9) compTime=29
#34: 'CPU-Core (SSE2/AES)': 566.2 PMKs/s (RTT 3.0) compTime=30
#35: 'CPU-Core (SSE2/AES)': 563.1 PMKs/s (RTT 2.6) compTime=24
#36: 'CPU-Core (SSE2/AES)': 590.0 PMKs/s (RTT 2.7) compTime=32
#37: 'CPU-Core (SSE2/AES)': 581.8 PMKs/s (RTT 2.7) compTime=27
#38: 'CPU-Core (SSE2/AES)': 566.3 PMKs/s (RTT 2.7) compTime=33
#39: 'CPU-Core (SSE2/AES)': 516.1 PMKs/s (RTT 3.2) compTime=28
#40: 'CPU-Core (SSE2/AES)': 561.8 PMKs/s (RTT 2.9) compTime=35
#41: 'CPU-Core (SSE2/AES)': 542.7 PMKs/s (RTT 2.9) compTime=29
#42: 'CPU-Core (SSE2/AES)': 577.9 PMKs/s (RTT 2.9) compTime=29
#43: 'CPU-Core (SSE2/AES)': 586.3 PMKs/s (RTT 3.0) compTime=33
#44: 'CPU-Core (SSE2/AES)': 523.4 PMKs/s (RTT 3.0) compTime=30
#45: 'CPU-Core (SSE2/AES)': 526.7 PMKs/s (RTT 2.9) compTime=32
#46: 'CPU-Core (SSE2/AES)': 505.5 PMKs/s (RTT 2.9) compTime=32
#47: 'CPU-Core (SSE2/AES)': 567.8 PMKs/s (RTT 2.7) compTime=24
#48: 'CPU-Core (SSE2/AES)': 521.5 PMKs/s (RTT 2.9) compTime=32
CUDA:
#1: 'CUDA-Device #1 'Tesla V100-PCIE-16GB'': 113061.9 PMKs/s (RTT 0.4) compTime=11.3
#2: 'CUDA-Device #2 'Tesla V100-PCIE-16GB'': 120956.8 PMKs/s (RTT 0.4) compTime=13.1
#3: 'CUDA-Device #3 'GeForce GTX 1080 Ti'': 94154.2 PMKs/s (RTT 0.4) compTime=13.3
#4: 'CUDA-Device #4 'GeForce GTX 1080 Ti'': 96664.7 PMKs/s (RTT 0.5) compTime=16.4

45秒のベンチに対して、10秒程度。たしかにCPUに比べてかなり短いですが、1秒中80ミリ秒、つまり8%しか使ってないことを考えるとあまりに長いです。Pyritくんが口ではGPUで実行している!といいつつ、実はそうでない部分がかなり占めていて、GPUを全然使い切れていない可能性はやはりかなり高い、と考えてよいでしょう。

もう一つ。CPUもGPUも、どっちも45秒のベンチマークのうち、けっこうの部分遊んでいます。htopであんまりコアを使い切ってない気がしたのもここで説明がつきました。これはどう解釈すればいいのかといえば…たぶん、ダミーのパスワードの生成が、追いついてないんじゃないでしょうか。CPUやGPUがすぐパスワードを処理して「もっとくれや!」って言うけれど、

["barbarbar%s" % random.random() for i in xrange(bsize)]
としてパスワードをどんどん作る処理が追いついてない。

月刊Pyrit、今月のまとめなのですよ

  • benchmarkはパスワードクラックの一部の処理しか測ってない
  • GPUもCPUも遊びまくっている
  • nVIDIAのプロファイラきれい(明らかにEclipse製)

さぁて、来月の月刊Pyritは何なのですよ?

いきあたりばったりで書いてるのでどうなるかはわかんないんですけど、このボトルネックたちと遊んでいきましょう。いいっすね、なんかやっと技術ブログっぽくなってきた。…台風の低気圧で今日はテンションがひくいのですよ。みなさまも強い風に飛ばされないように気をつけて〜。

そう言えば先月書いた正解のパスワード、検閲されちゃいましたね。「xxxxxxxxxxx」ですって、いったいどんな文字列だったんでしょう、前後の文脈から想像をかき立てられちゃいますねぇ。「えっちなことば」って昔こんな感じで生まれたのかな。フヒヒ。

GPUサーバーを静かにする話

はじめに

弊社にテスト用のGPUマシンがいらっしゃったわけですが、この評判がすこぶる・・・悪い。

とてーもお高いサーバーであるはずなのに、とても評判が悪い。

このサーバーはNVIDIAさんのライセンスのおかげでデータセンターに置けずオフィスに置いてあるのです。

があまりにもうるさすぎて、執務スペースを追い出されて廊下というか玄関?に置いてあります。

今回はGPUサーバーをどうにかしてみんなに受け入れてもらった話をしましょう。

現状把握をしよう

騒音を計測してみましょう。

1.起動した瞬間

BAKUON!

起動時はファンが前回になるため一番うるさいです。にしても100デシベルと言えば、自動車のクランクション等に相当し、聴覚機能に異常をきたすレベルだそうな。起動後落ち着いた状態(アイドル時)

2. 起動後落ち着いた状態(アイドル時)

騒々しい街頭や掃除機に相当するレベルだそうな。

3. 起動後落ち着いた状態@会議室

扉を1枚隔てた会議室での測定結果。会議室も会議ができないほどではないですが、余裕で音が聞こえます。

4. 執務スペースの社長の机

執務スペースは扉1枚隔てているものの、余裕で聞こえる、というかうるさい。社長の席がGPUサーバーに一番近いので、私の席だともう少し静か。60デシベルで、時速40kmで走行する車の車内に相当する騒音だそうな。

5. 起動後落ち着いた状態@オフィスの外の廊下

外でも余裕で聞こえる。これはそのうちビルの管理会社に怒られそう。

6. 起動後落ち着いた状態@オフィスの外のエレベーターホール

エレベーターホールまで行けばあまり気にならない。

が相変わらず聞こえる。

やはりなんとかせねば。

サーバーの設定を見直して静かにしよう

ところがこのサーバー、BIOSやUEFIにFANに関する設定がないんだなー。

じゃあどうするんだというとIPMIから設定する感じ。

サーバーらしく、大量のセンサーが存在。これらのセンサー値とファンを連動させることができます。

初期設定は最初から50%ぐらいの力でファンが回っていて、負荷がかかるとすぐに75%ぐらいまで上がるように設定されています。

が、廃棄はかなり涼しく、正直過剰冷却感否めず。

そこで以下のように変更してみます。

ある瞬間までは頑張って耐えますが、温度が上がってくると突然ファンを全開にして温度を下げる方針です。

本当はもう少し緩やかにファン回転数を上げて言ってもいいんですが、熱で壊れたりしたら嫌なのでコンサバに行きます。

さて結果はどうなりましたでしょうか。

結果発表

起動した瞬間はどちらも全力で動くので割愛します。

起動後落ち着いた状態

おおお、劇的に静かになりました。

起動後落ち着いた状態@会議室

ここまで来るとほぼ聞こえません。

起動後落ち着いた状態@オフィス街の廊下

こちらもほとんど聞こえません。というかサーバー自体より騒音値が上なので、廊下自体の騒音の方がうるさい感じです。

エレベーターホールは聞こえなかったのと、執務室は空気清浄機の音の方が支配的だったので割愛します。

番外編

弊社のデータセンターにも同じ機種のGPUがないものがあったので、ファンのチューニングをしてみました。

騒音はデータセンター自体元々うるさいので測定していませんが、アイドル時の消費電力が420W→160Wに減少しました。。。この機種の初期設定がひどすぎる説・・・。

結論

サーバーも設定次第で静かにすることが可能です。諦めずにチューニングしましょう。

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語法>

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