セーブポイント

特にジャンルの決まってない雑記です。

イラストから正方形のサムネイルを自動生成

イラスト調の画像から、(リッチなAIなどは使わずに)自動で切り抜き範囲を推定してサムネイルっぽいものを生成する方法の解説です。

これは以前にブログでも紹介したイラスト管理アプリに最近実装した機能です。以前は新規の投稿を登録したときに先頭の画像をそのままサムネイルとして一覧ページで表示していましたが、解像度の高い画像が多いのに表示するサイズは小さいため画像の読み込みで毎回無駄なリソースを使っている気がしたので、200x200サイズの画像を事前に生成してそれをサムネイルとして使うように仕様変更しました。

new-file.hatenablog.com

現行の仕様

プレビュー用の小さい画像を生成しておくというのは、画像をたくさん表示するギャラリーのようなWebサイトでよくとられる手法ですね。pixivとか。

表示するサイズは小さいのに毎回原寸大のデータを取りに行ってたら通信量もすごいことになるので、Webサイト運営をやるならほぼ対応必須の項目です。ただ、こういうケースではわざわざ自分で実装するよりCloudflare Imagesなどを利用した方が長期的にサービスを運営していく上で楽できるように思います。

自分の場合はデスクトップアプリだったので、自作で簡易的なサムネイル生成システムを実装してみました。この記事で紹介するサンプルコードは以下のリポジトリに置いてあります。

github.com

1. 顔認識でサムネ生成

どうやって機械的にサムネイル画像となる領域を決定するかですが、まず最初に思いつくのが顔認識です。キャラクターの顔が含まれる領域を選択すれば、それっぽいものができそうな感じがします。

顔認識をするとなると結構難しそうに聞こえるかもしれませんが、意外と簡単にできます。今回使うのは、OpenCVの物体検出の機能です。

これはCascade Classifierという機械学習ベースの手法ではあるのですが、非常に軽量で高速に動作します。分類器は以下の方が公開してくれているlbpcascade_animeface.xmlというやつを使います。GitHubのリポジトリからダウンロードしておいてください。

ultraist.hatenablog.com

github.com

自分の実装したシステムでは、thumbnail_generator.pyget_center_point内で使用されています。イラスト内に顔が複数含まれる場合、最も領域の大きいものの中心をサムネイルの中心座標として採用します。

def get_center_point(image_path: str, use_smartcrop=True):
  # 1. 顔検出
  np_array = np.fromfile(image_path, np.uint8)
  image_cv = cv2.imdecode(np_array, cv2.IMREAD_COLOR)
  gray = cv2.cvtColor(image_cv, cv2.COLOR_BGR2GRAY)

  cascade = cv2.CascadeClassifier(CASCADE_MODEL_PATH)
  faces = cascade.detectMultiScale(
    gray, scaleFactor=1.1, minNeighbors=5, minSize=(24, 24)
  )

  print(f"{len(faces)} face(s) detected")
  if len(faces) > 0:
    # 最も大きい顔を選択
    # 顔の座標: (x, y, w, h)
    x, y, w, h = max(faces, key=lambda f: f[2] * f[3])
    return (x + w // 2, y + h // 2)

この手法で例としていくつかサムネイルを自動生成してみました。左が元画像、右が生成されたサムネイルです。サムネが黒い画像となっているものは、顔が検出できなかった例です。

https://hateblo.mgcup.net/images/2026/03/result_summary_cascade.png

結果を見てもらえば分かる通り、以外とこの手法うまくいきません。元々正面顔でないとあまりうまくいかないらしいのですが、顔が正面を向いていてもちょっと周りに障害物があると検出できなかったり、そもそも横を向いていたりするとだめです。

モデルが結構古いため、最近のイラストだとうまくいってない気がします。明確に顔が描かれているイラストには有効ですが、もっと別のアプローチを考えたほうが良さそうです。

追記:顔検出をもっと頑張りたい場合

以下のyolov8_animefaceを使うとより高精度に顔認識ができそうです。10000件のイラストでトレーニングされたYOLOv8モデルが公開されています。

github.com

2. 画像の特徴を使う

顔を使ったサムネ生成をやってみましたが、顔を目印にするとキャラクターが後ろを向いているイラストやそもそもキャラクターが描かれていない風景画などに対応できません。

「もっと画像の色の情報などを活用できないだろうか?」と思っていたところ、以下のsmartcrop.pyというライブラリを見つけました。

github.com

もともとsmartcrop.jsというライブラリがあり、それをPython環境に移植したものです。

どのようなアルゴリズムで領域を決定しているのだろうと気になりましたが、READMEによると以下のような手続きになっているようです。古典的な手法のみで画像の切り抜きが実現されています。

  1. ラプラシアンフィルタによるエッジ検出
  2. 人の肌のような色を持つ領域を検出
  3. 色の彩度の高い領域を検出
  4. オプションで指定された領域の重みを強くする(顔検出など)
  5. 指定されたアスペクト比で、切り抜く領域の候補を大量に生成
  6. 評価関数で候補をランク付けする(中央付近にあるかどうか、画像の端付近を避ける)
  7. 最もスコアの良かった候補を出力

この手法の良いところは、どのような画像を入力しても必ず候補となる領域が得られるところです。顔が検出できなかった場合のような例外処理をしなくてよくなります。

今回自分が実装したものではOpenCVが顔を検出できなかった場合のフォールバックとしてsmartcropを使っていますが、このライブラリだけ使う実装にしても実用に耐えるかと思います。

ただ、古典的な手法だけ使っているとはいえ、入力画像が高解像度になると先程の手法よりもちょっとだけ重いです。

1.の手法のテストで使用した画像と同じものでテストしてみました。

https://hateblo.mgcup.net/images/2026/03/result_summary_sc.png

先ほどはうまくサムネイル画像が生成できなかった例でも、それらしい領域が切り抜けています。

extra. 人の手で調整する

smartcropでかなり高い品質でサムネイル画像が自動生成できましたが、しばしば「ちょっと切り抜く範囲を調整したい」と思うときもあります。

こういう場合でも対応できるように、GUIで切り抜き範囲を確認・修正できるようにしました。

thumbnail_generator.pyを実行して顔認識→smartcropによる領域の推定が終わると、以下のようなGUIが立ち上がります。

切り抜き位置の調整画面

最初に表示されているのはOpenCVかsmartcropが提案した切り抜き位置であり、位置を変えたければ黄色の枠をドラッグして移動することで切り抜かれる範囲を手動で修正できます。

修正する必要が無いと思った場合は「キャンセル」を押すことで表示されている範囲でそのまま切り抜きが行われます。

GUIはPySide6で作りました。作りましたというか、Geminiに書いてもらいました。

本当に数分くらいで作ったものなので、よしなに調理していただければと思います。

イラストのデータだけから出典・イラストレーターを探す方法

少し前にpixivの削除されたブックマーク作品を復元する方法の記事を書きました。

new-file.hatenablog.com

今回はこれとは逆で、イラストのデータだけ持っているときにその出典を逆引きする方法について解説します。

こういうケースはあまり多くはないと思うんですが、昔保存した画像とかでたまーにあるので。

イラスト向けの画像検索サービス

Google画像検索で探したら見つかった」というのであればそれでいいんですが、Google画像検索は結構広くヒットしてしまうので、ここではイラストに特化した画像検索サービスを紹介します。

1つ目はsaucenao.comです。pixivをはじめ有名どころのWebサイトを横断して検索できるサービスで、画像のソースのリンクとアーティストのリンクも提供されています。

トップページで画像のURLを入力するか手元の画像をアップロードして、検索するだけで使えます。

SauceNAOで検索した結果

ほとんどのケースがこのサイトだけで完結する気がします。DBのリストを見る限り漫画とかにも対応してそうです。

似たような機能を持つ他のサイトとして、二次元画像詳細検索があります。

検索のモードとして「色合検索」と「特徴検索」があるようです。詳細は説明ページへ。デフォルトは色合検索のようですが、特徴検索だとトリミングされた画像から元の画像を検索できるようです。

この2つのサイトで思ったような結果が得られなかったら最後の手段としてGoogle画像検索を使ってみる、といった流れで良い感じがします。

RTL-SDR Blog V4で航空管制(ATIS)を受信しよう

大学の講義でソフトウェア無線(SDR)を勉強する機会があり、面白かったので記事にします。

今回使ったのは、RTL-SDR Blog V4というPCにUSB接続して使うドングルです。

RTL-SDR Blog V4

実際に使うときは金色の部分にさらにアンテナを接続して使います。

ちなみに、アンテナセットで購入すると日本円で1万円弱くらいするみたいです。

「そもそもSDRって何?」というところですが、ハードウェアには電波を受信する最低限の機能だけ載せて、ソフトウェア側で信号処理することで色んな周波数のデータを受信できる、というものみたいです。ソフトを書き換えれば1つのハードを色んな役割に使い回せるわけですね。

GNU Radioのセットアップ

今回ATISの受信システムの実装にはGNU Radioを使います。Windows環境でGNU Radioを利用したい場合、radiocondaが便利です。

github.com

radioconda-installerのREADMEを少し下にスクロールすると以下の画像のようなところがあるので、自分の環境に応じたインストーラーをダウンロードしてください。

メジャーな環境向けのインストーラーが用意されている

また、Windowsでradiocondaを使う場合、別途ZadigでWinUSBドライバをインストールしておいてください。詳細は → こちら

航空交通管制(航空管制)について

さっそく実装、といきたいところですが、今回扱っている対象である航空交通管制について簡単に概要を説明しておきます。

ja.wikipedia.org

いや、まあWikipediaでいいんですけど。大事なのは、以下の点です。

航空交通管制に使用される無線電話における電波の変調方式は、全世界で振幅変調が用いられている。

つまり、周波数が分かればAMラジオと同じ感覚で音声を聴くことができる、というわけです。ただし、傍受は合法ですが傍受した通信の内容を一般に公開することは法律で禁止されています

その航空管制の業務の中に、ATIS(飛行場情報放送業務)と呼ばれるサービスがあります。

ja.wikipedia.org

空港に離着陸する航空機に対して、地上から音声による通信で離着陸に必要な気象情報、飛行場の状態などを提供するものです。

ATISは断続的に音声が流れるという点と、特に規模の大きい空港では24時間放送が実施されていて受信のテストがしやすいという2つの利点があり、今回はATISの音声を受信するシステムを実装してみることにしました。

ATIS受信システムを実装する

GNU Radio Companionを使ってシステムを実装します。

どの空港を対象にするかの話をしてませんでしたが、今回は羽田空港のATIS(128.8MHz)を受信できるようにシステムを作っていきます。ATISの受信は空港からの物理的な距離の制約をかなり受けるので、それぞれ住んでいるところから一番近い、かつATISを実施している空港を対象にすると良いと思います。周波数の設定の部分だけ読み替えれば同じように実装できるはずです。

日本のATIS実施空港等

今回組んだシステムは、以下のような感じです。

RJTT ATIS Receiver

AMラジオを受信するシステムを実装したことがある人が見たら、「見たことあるな」と感じると思います。

以下で簡単に解説をしていきます。

まず、Soapy RTLSDR Sourceでデータストリームを受信します。このとき、Center Freqは128.8MHzではなく128MHzに設定して、このあとのフィルタで周波数をシフトさせています。これは「Center Freqを直接合わせるとDCスパイクが信号に被ってしまう」みたいな話をGeminiから聞いたのでこうしたのですが……ソースの中心周波数を128.8MHzにしてその後ローパスフィルタに通すシンプルな構成と聴き比べたとき、正直そんなに違いが分からなかったです。差はあるのかもしれないですが、今回の実験ではSNRが結構悪く、違いがそんなに分からなかったということなのかもしれません。

後で実験するときにRF Gainをその場で調節できるように、GUIで操作できる変数(rf_gain)を設定しています。

次に、周波数を128.8MHzに合わせるためにFrequency Xlating FIR Filterを使います。このノードで周波数シフトとデータの間引き(デシメーション)をまとめてやってしまいます。Center Freqを800kに設定して、Tapsにはfirdes.low_pass(1, 2.4M, 10k, 5k)を設定しています。ローパスフィルタのパラメータのうち、第3引数(cutoff_freq)と第4引数(transition_width)は調整の余地がありそうです。

low_passのリファレンス

そのあとはRational Resamplerでもう一回データを間引いて周波数を48kHzに合わせて、Complex to Magで信号を復調します。

最後に後処理としてDC Blockerで直流成分を除去して、システムとしては完成です。ただし、等倍だと再生される音声の音量がかなり小さかったので、Multiply Constで雑に音量を上げています。

実験するときの注意点

ATIS受信システムを実装できたので実際に動かしていきたいところですが、先程も少し触れた通り実験する環境にかなり制約があります。

まず、このシステムを家の中で動かしてみてもホワイトノイズしか出力されないと思います。空港からめちゃくちゃ家が近い方だといけるのかな?自分の場合は羽田空港からそこそこ距離があるので、まったく意味のあるデータを受信できませんでした。あまりにノイズしか出てこないので、最初は「実装を間違えてるのでは?」と思ったくらいです。

自分のケースでは家の窓際にアンテナを設置してそこで実験してみたところ、かなりノイズが乗っているもののギリギリ聞き取れるくらいの品質でATISの音声が受信できました。あまりに窓際すぎて外が映るので写真を撮っていないし、受信した結果の音声も共有できないので伝わりにくいと思いますが……。

RF Gainを動かしたりシステム内のパラメータをちょっと変えてみたりして音質の改善を狙うこともできるとは思いますが、物理的な空港との距離・方角とか、アンテナの設置の仕方とかそういう部分を改善するほうが優先度高いな、と感じました。

普段はソフトウェア開発ばかりでレイヤー高めのことばかりしているので、こういったハードウェアで遊ぶのが結構楽しかったです。RTL-SDR Blog V4ですが、他にもADS-Bの信号を受信できたりします。結構面白くてハマる人もいるよなあと思いました。ちょっと受信用のキットがお高めではありますが、趣味としてやってみるのもいいんじゃないでしょうか?

【Minecraft】Cubiomes Viewerで変なseed値を探してみよう

久しぶりにMinecraftの記事を書きます。

イクラをやったことのある人、Chunk BaseというサイトのSeed Mapというアプリ使ったことありますよね?ワールドのseed値を入れると村の位置とかスライムチャンクの位置とかを出してくれるやつです。

www.chunkbase.com

MineAtlas使ってたっていうあなたは、Minecraft老人です。おめでとうございます。

今回はSeed Mapのような機能を持ちつつ、しかも欲しいワールドの検索機能がついたCubiomes Viewerというツールを紹介します。

ダウンロード

github.com

GitHub上で公開されています。

GitHubリポジトリのトップ

右側に「Releases」という項目があるかと思うので、そこの「cubiomes-viewer x.y.z」というのをクリックしてください。2026年1月時点では4.1.2が最新版のようです。(しばらく更新されてないみたいですが、そもそもマイクラ側が1.21の期間長すぎね)

cubiomes-viewer 4.1.2のリリース

色々ダウンロードできるものがありますが、Windowsを使っている人は末尾に「-static-w64.exe」とついているやつをダウンロードしてください。

Cubiomes Viewerの初期設定

ダウンロードしたexeファイルをダブルクリックすればそのまま起動できます。インストール等は不要です。

起動すると以下のような画面になると思います。

Cubiomes Viewerを起動した画面

最初はライトテーマになっているかと思いますが、自分の画面ではダークテーマに変えています。

ダークテーマに変えたい場合、画面左上メニューの「Edit」>「Edit preferences…」から設定を開いて「GUI Style」という項目を「Dark」に変更してください。


最初起動したままの状態だと画面右のマップにはバイオームの分布しか表示されていないかと思いますが、画面の右端に並んでいるアイコンをクリックすると構造物の位置を表示できます。

スポーン地点と村を表示した状態

また最初の状態では地形の高低差が分からないので、画面左上メニューから「Layer」>「Display Options…」を開いて「Approx. surface height」という項目を「Shaded biome map」に変更すると、上の画像のような状態になります。

Layer Display Options

Cubiomes Viewerを使ってみる

ツールを使って特定の条件を満たすseed値を探してみます。まず、スポーン地点に村があるワールドを探してみます。

Cubiomes Viewerの画面左側にConditionsと書かれたスペースがあるかと思いますが、ここにseed値の検索条件を登録していきます。

Addを押すと条件の設定画面が出てくるので、Condition TypeからOtherを選び、その隣の欄で「Spawn」を設定します。下のLocationの部分は変更せずにOKを押してください。

Spawnの条件を設定する例

これで、初期スポーン地点に(0,0)が含まれるという条件を設定できました。

次にもう一回Addを押して、Condition TypeをStructures、隣の欄でVillageを選びます。これも下のLocationの部分は変更せずにOKを押してください。

以下の画像のように、検索条件が2個設定されていることを確認してください。

初期スポーン地点と村の条件を設定した状態

検索条件が設定できたら、左下にある「Start search」ボタンを押すことで検索が始まります!
今説明したのと同じ条件で検索している場合、おそらく一瞬で以下のような結果が出ると思います。検索結果の行をクリックするとそのseedのマップが画面右側で開きます。

検索結果

どうやら3455というseedではワールドの(0,0)地点に村があるようです。実際にゲーム上で確認してみましょう!

seed=3455 (1.21.11で生成)

村発見

ということで、簡単にseed値の検索ができました。

似たような例で、初期スポーン地点が特定のバイオームであるようなseed値も検索できます。例えば、桜バイオームにスポーンするようなワールドを探してみます。

検索条件から村の条件を選択してから「Remove」を押すことで削除することができます。代わりに、「Add」を押して別の条件を追加します。

バイオーム条件の追加の例

上の画像のように、Condition typeを「Biomes」にし、その隣の欄には「Overworld at scale」を設定します。下に「Biomes」という項目があるので、この中から「Cherry Grove」を選択します。バイオームを選択するのを忘れないように。

この条件で検索すると、447というseedが見つかりました。これもゲーム上で確認してみます。

seed=447 (1.21.11で生成)

ちゃんと条件に合ったワールドになっています。

Sister seedsについて

先ほどスポーン地点に村がある例として3455というseed値を見つけましたが、実はこの情報から281474976714111も同じ条件を満たす可能性があることがすぐに分かります。

seed=281474976714111

Minecraftではseed値は符号付き64bit整数です。なので、生成されるワールドのパターンは 2^{64}=18446744073709551616 (約1840京)通り存在します。

これは正しいのですが、実は構造物の生成に関わるのはこのseed値のうち下位48bitだけです*1。すなわち、seed値の上位16bitがどのような値であろうと、下位48bitが同じならば同じ位置に構造物が生成されます

このように下位48bitが同じseed値のことをSister seed(あるいはShadow seed)と呼ぶらしいです。3455は16進数に直すと0000 0000 0000 0d7fで、2814749767141110001 0000 0000 0d7fなのでこの関係にあります。

ちなみに、構造物の生成時にseedの下位48bitだけが使われるのはjava.util.Randomの仕様のせいのようです。Javaの標準ライブラリであるRandomは64bitのseedを受け取りますが、実際に使用されるのは下位の48bitだけで、上位16bitは捨てられてしまうという仕様になっています。

構造物はseedの下位48bitだけ参照されるのに地形とバイオームはそうじゃないのはなぜ?という別の疑問が湧きますが、地形とバイオームの生成に関しては1.18 (Caves & Cliffs: Part II)以降はjava.util.Randomに依存しないアルゴリズムに変更されているとか*2。それより古いバージョンでは、構造物の生成と同じように地形生成でも下位48bitが同じだと似ているみたいな問題があったようです。

さらに余談ですが、最近のMinecraftではJava版でもBedrock版でもseed値が同じならエディションを跨いで同じ地形とバイオームが生成されるみたいです。そこまで同じになってるんですねー。

特に、32bitの範囲に収まる範囲のseed値であれば構造物の位置も含めてかなり似ているワールドになるとか?

珍しいseed値を探してみる

とりあえずCubiomes Viewerを使ってseed値の検索ができるようになったので、ちょっと複雑な例もやってみましょう。

村の近くに森の洋館がある、みたいなワールドを探してみます。

村の検索設定

1つ目の条件として村の検索の設定をします。Locationの部分を上の画像のように設定すると、「ワールドの原点を中心とした128x128の範囲に村がある」という条件になります。

森の洋館の検索設定

続けて森の洋館の条件を追加します。村の近くという条件にしたいので、1つ目の条件で見つけた村を基準に範囲を相対指定します。「Location is relative to」をワールド原点ではなく村に設定して、適当な範囲を設定します。

この条件で検索をかけると、以下のようになりました。検索ボタンの上にある「Stop on results」のチェックを外して検索をすると、複数件探すことができます。

村と森の洋館が近くにあるseedの検索結果

結果をいくつか見てこれが良さそうかなと思ったのでゲーム上で確認してみます。

seed=65100738

この設定だとちょっと洋館が遠い?気がしますね。

森の洋館の探索範囲を「村を中心に192x192」にちょっとだけ狭めてみます。条件を厳しくすると、当然それだけ検索に時間がかかるようになります。

seed=244822728

ちょっと近くなりました。村からすぐ行けて便利ですね。


あと最後に、孤島にある村とか探してみます。直接的に「島にある」みたいな条件は用意されていないのですが、村の東西南北が海系のバイオームであるという条件で検索します。

まず村の条件を追加したあとに、以下の画像のようにバイオーム条件を設定します。

「X=150が海系バイオームである」の設定

LocationのCustomに座標を指定して、下のBiomesの欄でOcean系のバイオームにチェックを入れます。ここで、いずれかのバイオームであればいいので「Match any」にチェックを入れておいてください。

あとはこれを真似して「X=150が海」「X=-150が海」「Z=150が海」「Z=-150が海」という4つの条件を設定してください。正しく設定できていれば、以下の画像のようになっていると思います。

「孤島に村がある」の設定例

あとは検索するだけなのですが、今回は画面左下の「Search」という項目を「48-bit family blocks」に設定して検索してみます。これは、先程説明した構造物の位置は下位48bitだけで決まるという仕様を利用して、逆に上位16bitを変えることでバイオームだけ変えて検索を効率化するという手法です。

「孤島に村がある」の検索結果

以下は見つけたseedです。

seed=5232056867097674387

砂漠の村、周りに木が全然なくてすごい↓

seed=-3730106391369611584

なんかまだ面白いことできそうなので、ある程度まとまったらパート2書きます。

参考にした動画

www.youtube.com

*1:統合版では下位32bit

*2:おそらくxoroshiro128++?

【AlpacaHack】Writeup: Ruby Flag Checker

問題リンク

Ruby Flag Checker - AlpacaHack

直接的な解法だけ知りたい方は一番下まで飛ばしてください。

Rubyについて知る

個人的にRubyをあまり書いたことがなく馴染みがなかったので、勉強しながら問題を解いてみよう、という趣旨の解説です。同様にRubyをあんまり書いたことない人に参考にしていただけたら嬉しいです。

問題のプログラムは1行に圧縮されてますが、以下のように3行に戻せそうです。

require 'prime';
print "flag> ";
puts(Prime::Generator23.new.take(23).zip(STDIN.read(23).bytes).map{|x,y|x^y}.pack("C*")=="Coufhlj@bixm|UF\\JCjP^P<"?"Correct!":"Incorrect!")

2行目は文字列を出力しているだけっぽいので、3行目の正誤判定の条件が分かれば問題が解けそうです。三項演算子で出力する文字列を振り分けているので、読解したいのは以下のコードです。

Prime::Generator23.new.take(23).zip(STDIN.read(23).bytes).map{|x,y|x^y}.pack("C*") == "Coufhlj@bixm|UF\\JCjP^P<"

というわけで、Rubyの文法を勉強しましょう。Rubyは日本発のプログラミング言語なので、日本語で言語仕様のドキュメントが読めます。

www.ruby-lang.org

このページにある「20分ではじめるRuby」を読んでもいいですし、既に他の言語に詳しい人向けに「他言語からのRuby入門」というページも用意されています。個人的にこのページがあるのは非常に嬉しいです。

個人的にJavaが雰囲気近いかなと思ったので「JavaからRubyへ」を読んでみます。「Javaとの違い」の中で今回の問題で関係ありそうだなと思ったのは以下の2つです。

  • 「メソッド呼び出しの括弧は基本的にオプションで、しばしば省略されます。」
  • Foo foo = new Foo("hi")foo = Foo.new("hi")と書きます。」

これらを合わせると、クラスからインスタンスを生成するときに引数を渡す必要がないなら、Foo.newとだけ書いてもいいということが分かります。

ここから、与えられたプログラムのPrime::Generator23.newの部分は、Prime::Generator23クラスのインスタンスを生成していると理解できました。

class Prime::Generator23 (Ruby 4.0 リファレンスマニュアル)

インスタンスを生成したあと、takeというメソッドを呼び出しています。このメソッドはEnumerableに定義されている関数で、先頭からn要素を配列として返す関数です。Prime::Generator23クラスは「2と3と、3 より大きくて 2 でも 3 でも割り切れない全ての整数を生成します。」とのことなので、[2,3,5,7,11,13,17,...]のような配列が生成されることになります。

生成された整数列に対して、さらにzipというメソッドを呼び出します。これはPythonなど他の言語にもあったりしますが、2つ以上の配列を「まとめる」関数です。STDIN.read(23).bytesについてのドキュメントでいいのが見つけられなかったんですが、これは「標準入力から23バイト分読み取ってバイト列に変換する」という意味だと思うことにします。

ここまでで[[整数1, 文字1], [整数2, 文字2], ...]という二次元配列が作られます。これに対して.map{|x,y|x^y}でさらに新たな配列を生成します。生成される配列の各要素はx^yになります。

最後にpack文字コードの数値列から実際の文字列に変換します。文字列型にはunpackというメソッドがありこれは文字列を文字コードに変換します。Pythonchrordです。

解法

与えられたプログラムは、Generator23で生成した整数列と標準入力から受け取った文字列をXORしたものが文字列Coufhlj@bixm|UF\\JCjP^P<に一致するかどうかでflagの正誤判定をしています。

ということは逆に、Generator23で生成した整数列と文字列Coufhlj@bixm|UF\\JCjP^P<をXORすれば入力するべきflagが得られるはずです。数学的に言えば、x \oplus y = z ならば  y = z \oplus x ということです。

与えられたプログラムをベースにして、以下のように改変するとflagが得られます。Rubyの実行環境が手元に無い場合、オンラインでRubyを実行できるサービスを使うと楽です。ここ とか。

require 'prime';
print "flag> ";
puts(Prime::Generator23.new.take(23).zip("Coufhlj@bixm|UF\\JCjP^P<".unpack("C*")).map{|x,y|x^y}.pack("C*"));

あるいは、他の言語で等価なプログラムを作成してもよいです。Pythonなら以下のように実装できます。

def generator23(length: int):
  pprimes = [2,3]
  
  num = 5
  while len(pprimes) < length:
    if num % 3 != 0:
      pprimes.append(num)
    num += 2
  
  return pprimes

pprimes = generator23(23)
XOR_KEY = "Coufhlj@bixm|UF\\JCjP^P<"

assert len(pprimes) >= len(XOR_KEY)

flag = []

for x, y in zip(pprimes, XOR_KEY):
  flag.append(chr(x ^ ord(y)))

print("".join(flag))

【AlpacaHack】Writeup: Twilight

問題リンク

Twilight - AlpacaHack

観察

配布されたchallのファイル形式を調べてみると普通のELFなので、とりあえず実行してみましょう。

$ file ./chall
./chall: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=cad54f8480fb7120a6bf06f1647f1801e946a02c, for GNU/Linux 3.2.0, not stripped

すると、flagの入力を促されます。適当にAlpaca{aaaa}と入力してみます。

$ ./chall
Enter the flag: Alpaca{aaaa}
0x41, 0x6D, 0x72, 0x62, 0x67, 0x64, 0x81, 0x66, 0x69, 0x68, 0x6B, 0x76, 

文字列を与えると、16進数の数値の列が出力されました。これは、配布されたout.txtに記載されているものに似ています。

今回与えられたバイナリはフラグチェッカーではなく、入力に何らかの変換を施すプログラムのようです。

Alpaca{aaaa}を入力したときの出力とout.txtの内容を比較すると、0番目から6番目までの数値が一致しています。これはAlpaca{の部分に相当すると考えられます。ここから、今回与えられたプログラムは入力された文字に1文字ずつ何らかの加工をして出力するプログラムと推測できます。また、out.txtに記載されているテキストはflagをこのプログラムに入力したものと考えてよいでしょう。

このことから、このプログラムが文字列にどのような操作をしているか、またその逆操作が分かればflagが取得できそうです。

解法

与えられたバイナリをBinary Ninjaで解析します。疑似Cコードで表示すると、以下のようになっているようです(本当はもっとごちゃごちゃしていますが、一部綺麗にしています)。

uint64_t a(int32_t x, int32_t y) {
  return (uint64_t)(x + y);
}

uint64_t b(int32_t x, int32_t y) {
  return (uint64_t)x ^ (uint64_t)y;
}

int32_t main() {
  printf("Enter the flag: ");
  char var_48[0x28];
  __isoc99_scanf("%33s", &var_48);
  uint64_t (* var_58)(int32_t arg1, int32_t arg2) = a;
  uint64_t (* var_50)(int32_t arg1, int32_t arg2) = b;
  int32_t var_5c = 0; // ループ用のカウンタ変数

  while ((int64_t)var_5c < strlen(&var_48)) {
    int32_t temp0_1;
    int32_t temp1_1;
    temp0_1 = HIGHD((int64_t)var_5c);
    temp1_1 = LOWD((int64_t)var_5c);
    uint32_t rdx_1 = temp0_1 >> 0x1f;
    printf("0x%X, ", (uint64_t)(&var_58)[(int64_t)(((temp1_1 + rdx_1) & 1) - rdx_1)]((uint64_t)var_48[(int64_t)var_5c], (uint64_t)var_5c));
    var_5c += 1;
  }

  putchar(0xa); // LF を出力
}

関数ポインタが使われていたりしてかなり実際の処理が分かりにくいです。ループの中のprintf文が今回の問題の肝なのでここを分解していきましょう。

まずループ中で定義されているtemp0_1temp1_1ですが、ここにはそれぞれvar_5cの上位32bitと下位32bitが入ります*1。つまり今回のケースでは常にtemp0_1=0です。ここからrdx_1=0も分かります。

ゆえに、(&var_58)[(int64_t)(((temp1_1 + rdx_1) & 1) - rdx_1)]という部分は(&var_58)[temp1_1 & 1]と簡略化できます。var_5cが偶数か奇数かによって呼び出す関数を切り替えているということが分かりました。

var_5cが偶数のときは(&var_58)[0](つまり関数a)、奇数のときは(&var_58)[1](メモリ上でvar_58var_50は隣接しているので関数b)が呼び出されます。

引数の方は簡単で、ユーザーからの入力var_48var_5c文字目と、var_5cが関数に渡されます。

これらをまとめると、与えられたプログラムchallは以下のようなプログラムと等価です。

#include <iostream>
using namespace std;

uint64_t a(int32_t x, int32_t y) {
  return (uint64_t)(x + y);
}

uint64_t b(int32_t x, int32_t y) {
  return (uint64_t)x ^ (uint64_t)y;
}

int main() {
  string s;
  cin >> s;

  uint64_t (*func_table[2])(int32_t, int32_t);
  func_table[0] = a; // var_58
  func_table[1] = b; // var_50

  int32_t i = 0;
  while (i < s.size())
  {
    uint64_t result = func_table[i % 2](s[i], i);

    printf("0x%X, ", result);
    i++;
  }

  cout << endl;
  return 0;
}

さて、「文字列に対してどのような操作がされているのか」が分かったので、このプログラムの逆の操作をするプログラムも作成できます。

#include <iostream>
using namespace std;

uint64_t rev_a(int32_t x, int32_t y) {
  return (uint64_t)(x - y);
}

uint64_t b(int32_t x, int32_t y) {
  return (uint64_t)x ^ (uint64_t)y;
}

int main() {
  int s[] = {0x41, 0x6D, 0x72, 0x62, 0x67, 0x64, 0x81, 0x46, 0x74, 0x79, 0x6B, 0x68, 0x6D, 0x45, 0x6F, 0x6C, 0x7B, 0x4E, 0x7B, 0x7D, 0x73, 0x42, 0x85, 0x79, 0x7C, 0x7C, 0x8C, 0x77, 0x7D, 0x73, 0x82, 0x62};

  uint64_t (*func_table[2])(int32_t, int32_t);
  func_table[0] = rev_a;
  func_table[1] = b;

  string flag = "";

  int32_t i = 0;
  while (i < 32)
  {
    uint64_t r = func_table[i % 2](s[i], i);

    flag += (char)r;
    i++;
  }

  cout << flag << endl;
  return 0;
}

関数aは文字のindexを足すという操作だったので逆にindexを引けば戻せます。また、XORの逆操作はXORです。

これを実行することでflagが取得できます。

*1:HIGHDとLOWDはBinary Ninja固有のマクロです。

【AlpacaHack】Writeup: Flag Printer 2100 (Daily AlpacaHack)

問題リンク

Flag Printer 2100 - AlpacaHack

解説

Binary Ninjaでmain関数を見てみます。

High Level ILでmain関数を表示した場合の図

flagを表示する関数の前にsleep関数が置かれているだけなようなので、これをNOPに置き換えるか引数を0にすれば良さそうです。今回はsleep関数の呼び出しをNOPに置き換えてみます。

Binary Ninjaの表示モードを「Disassembly」に切り替えるとcall sleep機械語ではe8bff6ffffであることが分かるので、バイナリエディタprint_flagを開いてE8 BF F6 FF FF0F 1F 44 00 00(5バイトのマルチバイトNOP)に置き換えます。

あとは編集したバイナリを実行すればflagが得られます。

マルチバイトNOPについては以下を参照↓

yaya.lsv.jp

ちなみに

今回手動でバイナリエディタを使って特定の命令をNOPに置き換えましたが、わざわざ他のソフトを使わなくてもBinary Ninjaに「Patch」という機能がありまして、これを使うとGUIで直接バイナリを編集できちゃいます。

call sleepの行を選択して右クリックすると、メニュー内に「Patch」という項目があります。

call sleepの行を右クリックしたときのメニュー

ここで「Convert to NOP」を選択すれば5バイト分の領域がNOPで埋められます。

「Convert to NOP」を実行した結果

「Skip and Return Value」や「Skip and Return Zero」なんかも用意されているので、CTFではかなり有用そうです。