匿名掲示板を作ってたらDDoS攻撃が来たのでCloudflare片手に戦ってた--2023晩夏
イントロダクション
前回の記事の通りで、趣味で専ブラに対応した掲示板を作っていた。 sasau.hatenablog.com
作ったものの、あまり宣伝や運営をする意欲もなかったので過疎掲示板としての時間が流れていた...。
というところまでが前回までの話だった。
その後、攻撃を受けるなどして、最終的になんでも実況Edge板は閉鎖してしまう...。のだけど、攻防の経緯をログとして残してあったので、この時どんなことを考えていたかも含めて、お伝えしておきたいと思う。
攻撃を振り返る
8/26
最初は8月26日に関連する掲示板に大規模な攻撃が来たことから始まる。
8月26日は、プロ野球のシーズン中で通常の試合が行われていた。野球に限らずだけど、実況民は実況中は本当に試合中のような行動をする。すなわち、実況中に何らかの要因でその会場が喪われると、安定した場所を求めて凄まじい勢いの大移動が起きる。エッジ板もそうした避難所の頭数となっていて、白羽の矢が立ったのが事の発端だった。余談だけどここで触れている防弾なんGは、その後も継続的なDDoS攻撃の標的にされてしまい、閉鎖の憂き目にあうことになる。
8/27~28
ここでDDoS攻撃を初めて経験した。エッジ板はCloudflareでのみ動いているため、そこまで遅くはならないだろうと高を括っていたのだが、明らかに読み書きのレスポンスが遅くなる。そのため、WAFを使ったIPアドレスベースのブロックを行うことになった。
CloudflareのWAFは無料でも超有能で、デフォルトでアクセスログにIPアドレスの所有元を示すASNまで提供してくれる。なので、これを検索することでリクエスト元を調べることができた。明らかに大量のリクエストを送ってくるASNはMiku Network Limited
やowl limited
など、聞いた事のない海外のVPS事業者からだった。
↑が8/27のアクセス量のグラフで、すこし分かりづらいけど、突然55k/hくらいのアクセスが発生していた。
8/29~30
8/29から本当の意味でのDDoS攻撃が行われることになる。この時点では攻撃リクエストには↑に書いているようにリファラに特徴があり、それによって大量の攻撃が一般回線から来ていることが浮き彫りになった。
このような攻撃はマルウェアに感染したゾンビPCにより構成されるボットネットから来ている可能性が高く、詳しくはわからないが、とある場所ではこのようなネットワークを時間貸ししているのだと言われる。
30日に実施したリファラによるブロックは功を奏し、一時的な平穏を得る。
9/3
少し空いて9/3、無意味な投稿をしてスレッドを埋めることを企図したスクリプトが発生した。これは本家5chでは日常的に発生しているため、予測したことであった。今後は書き込み前に
↑のように認証をしてもらうことでスクリプトを弾くことを目論んだ。
しかし...。
9/5
9/5、認証はhttps://d1ch.cc/authから書き込み時に返している6文字の英数の入力をすれば誰でもできてしまうので、なんらかの代行サービスを使ったのか簡単に突破されてしまった。
今思うとこの辺りでスクリプトに対して手動で対応していたのは疲弊の原因になった気もする...。
攻撃に関してのまとめ
- DDoS攻撃は脅威だが、リファラを使ったブロックがある程度功を奏していた、ただしこれは一時しのぎであることは明らかだった
- 認証は代行などを使えば認証済みのCookieを大量に抱えられてしまう問題があった
- 認証処理自体にもパフォーマンス上の問題を抱えていた
以上のような課題があり、パフォーマンス改善と、認証の脆弱性を塞ぐための改修を行うことにした。
9/9改修
9/9の朝、改修を行なった。それに伴いDBは作り直しになり、まっさらなところからの再出発となった。更新作業は滞りなく完了することができた。
技術的な詳細は↑の通り。
まず、DBにCookieテーブルなどがあって、これに認証フラグ等を持たせていたのだが、これが現状では明確に遅かったのでCloudflareのキーバリューであるCloudflare KVを使うことにした。Cloudflare KVはなかなかすごいというか、RedisのようなシンプルなAPIであるが、Redisのようには動かない。具体的にいうと、書き込んだあと、それが全てのノードに伝播するまで最大1分かかる。しかしReadは限りなく無限にスケールする。後述するけど、パフォーマンスとしてはこれが多大な効果があった。
また、喫緊の認証の問題は、専ブラでのリクエストに使われたIPアドレスと認証リクエストに使われたIPアドレスが一致することを要求し、また認証の操作を5分以内に行ってもらうことで対応した。
他には、専ブラは書き込み内容を特定の形式にして、それをShift-jisにしたものしか受け付けないのだけど、この処理を毎回GETリクエストが来た時に行うのはかなり遅いような気がした。そこで、d1のSQLiteにblob型のカラムを用意し、Shift-jisにしたものをそこに入れてしまって、書き込みを返すときは単なるバイト列のconcatだけで済むようにといった地味な最適化も行った。
改修の成果
まず、認証の脆弱性を突いた攻撃は発生しなくなった。これは予想外の結果だった。認証代行を使うのは難しくなったとはいえ、まだ手動で認証を済ませたCookieを作り出すことは可能であり、嫌がらせに使ってくるかなと思ったため。労力に見合わないと判断したのか、何か使いたくない事情があるのかなと考えていた。
しかし、IPアドレスの一致を要求することは、いくつかの技術的な問題を誘発した。専ブラによっては常にIPv4で通信しているものがあり、その場合ブラウザがIPv6を使って通信してしまうと何度やってもIPアドレスが一致しない。このような場合はユーザー側がどうにか対応する必要があるなど、大小の問題が発生した。
次にパフォーマンスについて。改修前はどうしても60k/hくらいで遅くなり始め、80k/hくらいからエラーが出始めるような感覚だった。
改修後は、200k/hを超えてもほぼ負荷を感じないレベルまでパフォーマンスの改善が見られた。この日はタイガースのアレ前夜。
この日はタイガースの「アレ」の日。
ここまでいくと、DDoS攻撃なのか、純粋な住民の熱狂なのか区別はつかないが、少なくとも読み込みにおいて「重い」と感じることは減っていった。
負荷が集まると3分程度500エラーが発生してしまうことはあり、完全な無停止とは行かないものの、掲示板としては及第点を取ることはできていたように感じる。
ただ、パフォーマンスとのトレードオフとはいえ、Cloudflare KVの最大1分の伝播時間の問題は地味にご不便をおかけしたように思う。認証したのに通らなくて不快に思われたことがあれば申し訳なく思う。
その後、閉鎖まで
技術的には諸々克服の兆しが見えていたのだが、精神的にはかなり疲弊していた。
特にDDoS攻撃は可視化された悪意の数字であり、Cloudflareのダッシュボードにログインしてこの数値を見るたびに心身の健康が少しずつ削られていった。
これはある日のアクセスログであり、どちらかといえば脅かしに近いのだが4M/1hのリクエストが送られてきていた。これもダメージが大きく、この状況では継続が困難であると判断した。10月1日に閉鎖の告知を行い、なんでも実況Edge板を閉じさせていただくことにした。
叩かれると思っていたのだが、告知のスレッドでは暖かい言葉を掛けていただき、本当にありがたかった。
なんでも実況Edge板まとめ
書き込み数: 1,426,894
スレッド数: 18,187
実況的にあったこと
- プロ野球のペナントレースの実況
- VIVANT最終回
- ラグビーワールドカップ
- 阪神タイガース・オリックスバファローズのリーグ優勝
- Nintendo Direct
- その他テレビ番組の実況…など
掛かった費用について
↑が実況的にはかなり回転していた9月の領収書。元々払っていたCloudflare Workersの5$プランを入れても費用は18$程度で収まっている。 ただし、d1はα版を使用していたためか請求に含まれていない。しかし、これがWorkersと同程度の金額だとしても30$弱であり、あり得ないほどの盛況でも50$程度を見ればある程度運営できるのではないか、と思う。
感想
結果的には色々なものに負けてしまい、申し訳ない気持ちと残念な気持ち、またたった一人でしか事に当たることができなかった自分の限界などを感じる。しかしながら、エッジコンピューティングを使ったアプローチでDDoSやスクリプト攻撃に対しても一定の可能性を示すことはできたかなと思っており、そこに関しては成果かなと思う。
短い間だけど自分のコードで戦うような「エッジランナー」の心持ちでいられたことはなんだか幸せなことだなと思っていた。
使っていただいた方へ
住民の方には、もっと早く感謝や、多少でも有益な情報を届けたい思いはあったのだけど、閉鎖の判断を下すのは自分の中でとても辛く、苦い思い出になってしまっており、様々な思いを無視する形で気がつけば今年も最後の日になってしまっていました。
今日までその後の情報は入れることが出来ていませんでしたが、現在ではこの技術スタックを用いた避難所開発のコミュニティができつつあるようで、救われた思いになりました。もちろん自分はその成果を何ら主張する立場にはないですが、それらの活動に最大限の感謝とリスペクトを送らせてください。ありがとうございます。ここでの情報が何かお役に立てば幸いです。
cloudflare d1とhonoで5ch型掲示板を作ってみた
作ったものはここに動いているので、ちょっとでも見ていってくれると嬉しい。
作った動機
この記事で、Cloudflare d1というサービスが開発されつつあることを知った。 zenn.dev
簡単に言うと、Cloudflare WorkersというCDNのエッジノードで動くFunctionサービスがあって、Cloudflare d1はそこにSQLiteも配置しちゃうぜ大作戦。
SQLiteは単なるファイルをDBとして使う技術であり、常駐するサーバープロセスが必要ないので、他のミドルウェアに比べるとかなり安くなりそう。安価なDBサービスを探していた自分も興味を持って色々と試していた。
大規模に使う場合、ホットスポットが予想して、アクセスが少ないものは他のストレージに退避しつつ、部分的に乗せる、みたいな工夫が必要になるかもしれません。 ただこれも D1 で sqlite テーブル単位の分散ができるようになったら解決するかもしれません。
Cloudflare d1のボトルネックとして、↑は自分もそう思っていて、とすると5chの板のように、スレッドの量が増えると書き込みがないスレがdat落ちし、アーカイブに似た、過去ログに移動するという仕様は最適では?と考えていた。
一方、これを作ろうと思った時はちょうど2023年のWBCの頃で、なんGやなんJがスクリプトやDDos攻撃によるサーバーダウンに悩まされていたので、よし一丁作ってみるか、とやってみることにした。
開発のこと
技術スタックはだいたい以下
- Cloudflare Workers
- Cloudflare d1
- hono
- drizzle-orm
他には専ブラが投げてくるリクエストを覗くのはProxyman(https://proxyman.io)も大変役に立った(無料でも単用途の範囲なら十分使える)。専ブラはどれもProxy機能を備えているのも大きかった。
実装したことは以下
- dat API
- スレッド一覧
- 書き込みGET
- 書き込みPOST
- トリップ計算
- 書き込み者ID
- スレッド終了時の処理
- 圧縮ワーカー(いわゆるdat落ちをさせる仕組み)
- クッキーの返却とIPアドレスの紐付け
- クッキーのRECAPTCHA認証(必要であれば)
5chは旧世代のCGIで実装されているものであり、専ブラ対応の掲示板を作るのにそんなに難しいことは必要なくて、必要なことは大体と〜く2ちゃんねる - Talk 2chに書いてある。ただし文字コードはShift-JISなので気を配る必要があるし、Shift-JISで表現できない絵文字を返す時は実体参照にする必要がある。その点honoはShift-JISにエンコードしたByte列を返せば問題なく動いてくれた。
更に補足すると、dat APIはスレッドの差分取得がキモになっている。具体的に言うと専ブラは欲しい範囲をRangeヘッダーで指示してくるので、これの挙動を愚直にSQLで再現するのが面白いところだった。
また、Cloudflare Workersを使って開発する時はCloudFlare Tunnelで自宅サーバーを公開する(FreeプランOK)を使うのが便利で、自分の場合、xxx.d1ch.ccみたいなサブドメインを開発環境のポートとして公開することで、開発環境と本番環境の差異なく開発することができた。このシステムで言うと、書き込み者IDの計算などにクライアントのIPアドレスが必要なんだけど、ローカルホストだと取得できないが、トンネルしているドメインからアクセスすることでCloudflareがCF-Connecting-IP
というヘッダーにIPアドレスをセットしてくれたりする。
honoは、単なるCloudflareとブラウザとの境界面という役割以上に、[Cloudflare Workers] HonoにJSXミドルウェアが追加されましたで紹介されているhono/jsx
とhono/html
の合わせ技を使うと、昔ながらのSSRなんだけどシンプルなフレームワークならではの不思議な開発者体験があり、なんだかとても直感的な感じがした。
d1で気になったこと
d1自体はまだOpen alphaであって全くProduction Readyではないので、僕が気にするまでもないことだとは思うけど、冒頭で挙げたボトルネックに加えて、writeがスケールしないのが気になる。現状だと、1つのCloudflare Workersに1つのDB、1つのファイルしかマウントできないので、水平分割をするにしても垂直分割をするにしても、APIで動的にファイルを増やせるような機能が必要だと思われる。
また現状のd1にはトランザクションがないので純粋に困る部分がある。これに関しては代替機能としてBatchがあり、drizzleではPRが出ているのでみんな楽しみにしているという感じ。
作ってみてわかったこと
ある程度動くものができてわかったけど、作るのはとにかく楽しかったが、こういう場を運営することに対する意欲や能力はないということである。
また、現在はいろいろあって住民が離散してしまったので、更に分散させるのものなあ、という感じ。
某所でちょっとだけ紹介したところ、クソスレも含めてスレッドを立ててくれる人がいて、いわゆる過疎掲示板のような様相になっており、そんな感じの書き込みがエッジノードに存在しているという事実がなんだか楽しい。
裁断くんver1.3
をリリースした。
今回の改修は「一枚の画像から複数枚切り出せるようになって欲しい」と身内からの要望があったのでその機能の追加をした。
新しいUIは以下の通り。
また、見開きの本のような画像を自作して、スクリーンショットやデモで使うことにした。
元々「非破壊自炊を支援する」というコンセプトではあったのだけど、iOSのシミュレーターに最初から入っている画像をデモに使っていたせいか、その辺りが伝わりづらかったと感じていたので、わかりやすくなればという意図がある。
以前のデモ→https://youtube.com/shorts/SAxWdQYKzaQ
開発のこと
UIの実装の手間的にも切り抜き枠は色で識別できるようにしたかったので、色決めがなかなか難しかったところで、妻に手伝ってもらった。
感想
今回の機能追加で、前回追加したPDF保存の機能と併せて、それなりに強力なものになったかなあという感覚がある。
まあ、Adobeや他社のスキャナアプリを使えば紙面の歪み補正機能なんかもあったりするのでそっちを使えばいいじゃないという気もするが、軽量さや作業が苦にならないようなUIを提供することによって選択肢になれるように、引き続きやっていきたい。
裁断くんver1.2
Apple税を払うのを忘れて公開が停止されてしまっていた...。
このアプリは全く使われていないようで、久しぶりにアナリティクスを開くと多くはないけど500くらいはユニット数(=インストール数)があり、せっかく容量を使って入れて貰ったのに申し訳ない気持ちになる。
僕は動画投稿者やvtuberは細部まで気遣いが行き届いてる人が好きだけど、自分はそういう誠実な開発者にはなれなかった...。
新機能は次の通り
- 処理した画像の保存先に「アルバムを新しく作ってそこに入れる」「画像1枚1ページのPDFを作る」という機能を追加し、選択できるようにした。
- 10枚以上画像を処理すると待ち時間にインタースティシャル広告を表示するようにした。
バグ修正
- 処理済みの画像を削除すると必ずクラッシュするという大バグが放置されていたのでそれを修正した。
- その他、最新の環境でビルドしてシミュレーターでデバッグしていると細かい表示上の不具合が大量にあったので修正した。
感想としては、ほぼChatGPT無双だった。
今回のdiffは8割くらいChatGPTの生成したコードを組み込んだんじゃないかと思う。
具体的なところを挙げると
このコードはそのまま使えた。自分はPhotosよくわからん、苦手意識すらあったけど、概念的なレベルから解説して貰えた。自力で書くと数十分は消費したんじゃないかと思う...。
あまり使ったことのないパターンマッチのコード生成してもらった。
細かい表示バグの中に、AutoLayoutを使っているViewが動いてしまうという不具合があったんだけど、この辺の疑問にも答えてもらえた。
プログラミングをやる上で、なんとなくそうかなと思っているけど明確な正解かどうかはわからないという感覚は付きもので、非プログラマーの人でも、仕事でプログラマーと話しても自信がなさげな返答しか得られないというのはそういう部分に起因していると思うんだけど、自分専用の正解を用意してくれるというのは今までにないとても新鮮な感覚で、最後までそんなに疲れずにリリース作業まで行うことができた。
ついでにいい感じのプライバシーポリシーも作ってもらえた。
→https://gist.github.com/sasaujp/cff574d5dd7eeecf7e179b6efd4644b6
正直ライブラリの使い方に詳しいみたいなエンジニアは身の振り方を考える必要性を感じる。AIと競うのはやめてAIを使う側にいこう。
そんなこんなで、今回の更新を終えることができた。以下は今回実装したPDFの作成のプロセスで広告の表示も含めて結構気に入っている。どうだろう?
最適化系の問題を解いてみる。完結編
6年前に書いた記事があった。
ここでは僕の好きなhttps://checkio.org/のある問題を通して、最適化系の問題を解いていた。
それで、まあ、プログラミングの問題としては一応解けたわけだけど、最適化についてなんか、こういう方法に落とし込めば解ける、のようなものがあるのではないかという課題感を残したままだった。
そこから時は流れ...世の中には線形計画法というすごい手法があることを知った。
それを使って、この問題を解いてみた。
pulpは線形計画法のライブラリ→ https://coin-or.github.io/pulp/
import pulp from typing import Tuple, Set from functools import reduce def break_rings(input_value: Tuple[Set[int]]): # # 問題定義 # つながっているリング p: pulp.LpProblem = pulp.LpProblem("BreakRings", sense=pulp.LpMaximize) # 存在するリングを列挙 # {1, 2, 3, 4, 5, 6} all_rings = reduce(lambda cur, pair: cur | pair, input_value) # 変数を定義する。それぞれのリングを残す場合を1, 壊す場合を0とする。 # {1: x1, 2: x2, 3: x3, 4: x4, 5: x5, 6: x6} ring_variables = { num: pulp.LpVariable(f"x{num}", 0, 1, cat=pulp.LpInteger) for num in all_rings } # リング、x1~x6までの合計を目的関数にして、これを最大化する。 p += sum(ring_variables.values()), "目的関数" # リングの繋がりの制約. リングのペアは全て壊れているか、1つだけ残っているかのいずれか。 # つまりリングのペアの合計は1以下 for ring_pair in input_value: ring1, ring2 = ring_pair p += ring_variables[ring1] + ring_variables[ring2] <= 1 # # 解析 result = p.solve() pulp.LpSenses[result] v = pulp.value(p.objective) # 求めるのは壊す回数なので残ったリングの数を最初のリングの数から引く print(len(all_rings) - v) for v in p.variables(): print(f"{v} = {pulp.value(v)}") if __name__ == "__main__": # 簡単な問題 break_rings(({1, 2}, {2, 3}, {3, 4}, {4, 5}, {4, 6}, {6, 5})) # 少し複雑な問題 break_rings( ( {3, 4}, {5, 6}, {2, 7}, {1, 5}, {2, 6}, {8, 4}, {1, 7}, {4, 5}, {9, 5}, {2, 3}, {8, 2}, {2, 4}, {9, 6}, {5, 7}, {3, 6}, {1, 3}, ) )
実行結果は
(...中略) 3.0 x1 = 1.0 x2 = 0.0 x3 = 1.0 x4 = 0.0 x5 = 1.0 x6 = 0.0 (...中略) 5.0 x1 = 0.0 x2 = 0.0 x3 = 1.0 x4 = 0.0 x5 = 0.0 x6 = 0.0 x7 = 1.0 x8 = 1.0 x9 = 1.0
となる。
これらの結果は正しい。
まとめ
前回までで
ペアのどちらかに該当する数字で、全てのペアを網羅するリングの数字を列挙すれば良いのでは?
というように問題を観察して、モデル化するところまでは済んでいたのでその通りに数式に落とすことで解くことができた。
とはいえ、前回までは
やる前は、知ってるか知らないかで終わるような解答があるのではみたいな甘えた考えがあったが、まあ、ないよな...。
というように、なにかこういう問題について明瞭な解き方のようなものがあるのではないかという疑問が残った形で終わったが、以下のように回答を得ることができた。
- モデル化を行い数式に落とし込むことで機械の力を借りて解くことができる
- 線形計画法は便利
知っている人にはひどく当たり前のような話なんだろうけど、学んだことが過去自分が取り組んだ問題に繋がると成長を実感できる。
棚上げテキストVersion 1.1.2
をリリースする(した)。
今回の変更点はレイアウト変更と削除確認機能の2点。
レイアウト変更
今回と前回の差分
画面のレイアウトをMaterial UIっぽく右下に追加ボタンを移動した。
これは次回以降のアップデートでナビゲーションバーの右にハンバーガーメニューを設置したくて、そのための場所を空けることを意図している。
多少考えたけど、リスト表示は結局こういうレイアウトになるんだなと思った。
削除確認機能
ファイルやディレクトリを削除した時に、4秒間「元に戻す」ボタンを表示するようにした。
最近こういうUIをよく見るようになった気がしていたので、実装してみる機会ができてよかった。
その他
今回のアップデートはさほど労力を要しなかったので、そろそろエクスポート機能をつけられるかなあと思う。
やっていきたい。
裁断くん1.1
をリリースした。
apps.apple.com身内からの要望で、というかBook Shooter MK-Vの仕様なんだけど
本のページを撮影する時、毎回本をひっくり返すので、奇数ページ、偶数ページで画像を回転できるような機能を追加して欲しいという要望があった。
のでその機能を実装した。
個人的にスマホはiPhoneではなくなったし、DL数もないし...とも思ったが、久々にAppStore Connectを開くと一件レビューがついてたのでやってみることにした。
このアプリのコア部分をコードで示すと、だいたい以下のような感じで、画像のArrayに対して要望に応じた処理をするということなので
なんとなくFunctional Programmingみたいな趣があるなと思いながら実装していた。(実際のコードはそういうわけではないが)
const images = getImages() const cropRect = getCropRect() const newImages = images.map((image, index) => { const croppedImage = image.crop(cropRect) const rotateAngle: number = getRotateAngle(index) const rotatedImage = croppedImage.rotate(rotateAngle) return rotatedImage }) save(newImages)
他には、今後画像処理の内容を増やすことを見越して(やるのか?)設定欄的なUIを設置した。
何か名前が存在しているのかは知らないが、Google Mapのアプリにあるやつ。
片手で操作するアプリだと割と最強だと思っていて、実装するのも簡単なのでつい頼ってしまう。
正直自分で使うわけではなく、用途もそんなに思い浮かばないのだけど手触り感の良いUIを考えるときは生きていると言う感じがする。
次は処理済みの画像をアルバムを作ってそこに保存できるようにする機能と、処理前に動画広告を再生を強制する機能の実装を進めたい。