技術部の遠藤(@mametter)です。おまたせしました、RubyKaigi 2022で出題したクックパッドブースの企画、Cookpad Code Puzzle for RubyKaigi 2022の裏ステージの解説です。
このパズル自体の解説は前編の記事をごらんください。
techlife.cookpad.com
さっそく11問目から解説していきます。
11問目
p func11(0)
p func11(1)
p func11(2)
p func11("foo")
何を与えてもよくわからない整数が帰ってきますね。リロードすると結果が変わることにも気づくかもしれません。つまり、これはハッシュ値であろうと当たりがつきます。ということで答えはこちら。
def answer11(v)
v.hash
end
11問目からは問題文もないし、知らないと解けない問題が多めになります。
12問目
p func12(0)
p func12(1)
p func12(2)
この辺を見てても1ばかり帰ってきますね。1以外になるところを探してみましょう。
300.times { p [_1, func12(_1)] if func12(_1) != 1 }
[11, 2]
[22, 2]
[33, 2]
[44, 2]
[55, 2]
[66, 2]
[77, 2]
[88, 2]
[99, 2]
[100, 2]
[101, 2]
[110, 2]
[111, 3]
[112, 2]
[113, 2]
...
ゾロ目が目に付きますが、100
や101
も2になることから、同じ数字の出現数が関係しそうです。確認してみましょう。
p func12(1111)
p func12(11111)
p func12(111111)
では、違う文字が複数ある場合は?
p func12(1122)
p func12(111222)
p func12(11112222)
p func12(11)
p func12(11222)
p func12(1122233333)
こういうのとにらめっこすると、文字ごとの出現数の積とあたりがつくのではないでしょうか。なのでこれが答え。
def answer12(n)
n.to_s.chars.tally.values.inject(&:*)
end
13問目
100.times { p [_1, func13(_1)] }
[0, "AS"]
[1, "UT"]
[2, "US"]
[3, "UT"]
[4, "UT"]
[5, "UT"]
[6, "UT"]
[7, "UT"]
[8, "UT"]
[9, "UT"]
...
[22, "IS"]
[23, "IS"]
[24, "IS"]
[25, "IS"]
[26, "IS"]
[27, "IS"]
[28, "IS"]
[29, "IS"]
[30, "IS"]
[31, "IS"]
[32, "IS"]
[33, "IS"]
[34, "IS"]
[35, "IS"]
[36, "IS"]
...
この問題はもう経験と勘を働かせるしかないです。先頭2文字を取っているのだろうと予想し、"UT"
で始まる用語というと、UTC
かUTF
かな?などと考えます。すると、"IS"
は"ISO"
と当たりが付き、RubyがサポートしているEncodingの一覧では?と考えついてください。ということで答えです。
def answer13(n)
s = Encoding.list[n]
s.name[0, 2] if s
end
func13
からfunc15
は、正体に気づかないまま、引数と返り値の対応をすべて記憶してテストを通した人が多かったかもしれません。それもよいと思います。
Table13 = {}
1000.times { Table13[_1] = func13(_1) }
def answer13(n)
Table13[n]
end
14問目
100.times { p [_1, func14(_1)] }
[0, "Z"]
[1, "O"]
[2, "T"]
[3, "T"]
[4, "F"]
[5, "F"]
[6, "S"]
[7, "S"]
[8, "E"]
[9, "N"]
[10, "T"]
[11, "E"]
[12, "T"]
...
これはよくある謎解きです。O→T→T→F→F→S→S→E→? という出題形式が多いかも。
答えを言うと、数字を英語で言ったときの頭文字です。One、Two、Three、Four、Five、……。ということで答え。
def answer14(n)
raise "The argument should be an integer from 0 to 1000" if n < 0 || n > 1000
if n <= 20
"ZOTTFFSSENTETTFFSSENT"[n]
elsif n < 100
answer14(n / 10)
else
answer14(n / 100)
end
end
なお、func14(1000)
が "T"
になるのは意図してないバグでした。One thousandなので"O"
が正しそう。ハマったひとがいたらごめんなさい。
15問目
100.times { p [_1, func15(_1)] }
[0, "AR"]
[1, "AR"]
[2, "Ar"]
[3, "Ar"]
[4, "Ba"]
[5, "Bi"]
...
この問題も経験と勘です。"RU"
で始まるものが多いあたりで"RUBY"
と気づけるかどうか。これはトップレベルの定数名の先頭2文字です。ということで答え。
def answer15(n)
s = Object.constants.sort[n]
s.to_s[0, 2] if s
end
16問目
p func16
call
というあたりから、ブロック引数を受け取っているのでは、と考えます。
p func16 {}
false
が帰ってきました。false
を返すからには、true
を返すこともあるはず。ブロックの返り値をいろいろ試してみます。
p func16 { 0 }
p func16 { 1 }
p func16 { "foo" }
false
から変わりません。ブロックに引数が渡されているのでしょうか?
func16 {|*a| p a }
なにも渡されていない……いや、可変長引数で見えない引数がありますね。たとえばselfです。
func16 { p self }
残念、selfは変わっていませんでした。もうひとつ見えない引数があります。ブロック引数です。
func16 {|&b| p b }
ビンゴ。ブロックが渡されてるようなので呼んでみましょう。
p func16 {|&b| b.call }
やった、返り値がtrueに変わりました。ということで答え。
def answer16(&blk)
flag = false
blk.call { flag = true }
flag
end
たぶんこれが一番むずかしい問題だったのではないかと思います。ブロックにブロック引数を渡すこと自体がマイナーだし、気づきにくいですよね。解けた人はすごい。
17問目
func17(0)
実行するとJSのalert("0")
が出てきます。この問題はRuby on WasmのJS連携を試してもらいたくていれました。
Ruby on Wasmのドキュメントを頑張って読み始めてもいいですが、適当な関数でテストを走らせてみるとRuby on WasmのJS.eval
へのURLが出てきます。
def answer17(n)
end
--- testing answer17
test17.rb:10:in `test17': JS's alert must be called (Hint: https://github.com/ruby/ruby.wasm/blob/194f4a1dfe9036018fef9810d71e23a24cd97bd9/ext/js/js-core.c#L81) (RuntimeError)
ということで答え。
def answer17(s)
JS.eval("alert(#{ s.to_s.dump })")
end
18問目
func18
をいろいろ呼んでもよいですが、この問題は適当なanswer18
を定義したほうが早かったかもしれません。
def answer18(s)
end
--- testing answer18
test18.rb:4:in `block in test18': answer18("0") != func18("0") (RuntimeError)
from test18.rb:2:in `upto'
from test18.rb:2:in `test18'
テストの通り、"0"
を渡してみましょう。
p func18("0")
整数にして配列にする?ということで試します。
def answer18(s)
[s.to_i]
end
--- testing answer18
test18.rb:10:in `block (2 levels) in test18': answer18("80+41") != func18("80+41") (RuntimeError)
from test18.rb:8:in `each'
from test18.rb:8:in `block in test18'
from test18.rb:6:in `times'
from test18.rb:6:in `test18'
"80+41"
も渡されるようです。
p func18("80+41")
これを繰り返すうちに、文字列を分解すればいいのだとわかります。ということで答え。
def answer18(s)
s.scan(/\d+|[+\-*\/()]/m).map {|s| s =~ /\d+/ ? s.to_i : s }
end
これは「字句解析」と言われる処理です。
19問目
18問目と同様にテストの失敗を観察していくと、こういうような挙動をすることがわかります。
p func19([2, "*", "(", 3, "+", 4, ")"])
これは構文解析ですね。「再帰下降パーサ」で検索するとCでのコード例が見つかります。これを移植したらOK。
def factor(tokens)
t = tokens.shift
if t.is_a?(Integer)
["value", t]
elsif t == "("
r = expr(tokens)
tokens.shift
r
else
"unknown: #{ t }"
end
end
def term(tokens)
r = factor(tokens)
t = tokens.first
while t == "*" || t == "/"
r = [tokens.shift, r, factor(tokens)]
t = tokens.first
end
r
end
def expr(tokens)
r = term(tokens)
t = tokens.first
while t == "+" || t == "-"
r = [tokens.shift, r, term(tokens)]
t = tokens.first
end
r
end
def answer19(tokens)
expr(tokens)
end
この問題が一番面倒くさかったのではないかと思います。
20問目
ここまで来た人なら、func20
はこういう挙動だとわかるでしょう。
p func20(func19(func18("2*(3+4)")))
ということで、func18
、func19
、func20
は四則演算の字句解析、構文解析、評価器という構成でした。答えはこんな感じ。
def answer20(e)
case e[0]
when "value"
e[1]
when "+"
answer20(e[1]) + answer20(e[2])
when "-"
answer20(e[1]) - answer20(e[2])
when "*"
answer20(e[1]) * answer20(e[2])
when "/"
answer20(e[1]) / answer20(e[2])
else
raise "unknown operator: #{ e[0] }"
end
end
これで全問突破です!おめでとうございます!
まとめ
前後編の長い記事になってしまいましたが、Cookpad Code Puzzle for RubyKaigi 2022の解説でした。
隠された関数の定義を当てるという問題形式 *1 は、いくらでも難しい問題を作れてしまうので、事前に社内でテストプレイをするなどして難易度調整に腐心しました。思ったより多くの人がfunc20
まで解いてくれたのでホッとしました。
クレジット:一部の問題は同僚のささださんの発案だったり、@hirekokeさんの発案だったりします。
おまけ:チート対策
Rubyにはこの手のパズルを台無しにするいろんな機能があります。このパズルでは、それらの機能をそこそこ無効にしていました。ただ、潰しきれなかった機能もあります。どのような対策をしたか、それを乗り越えるチート方法などを紹介します。
テスト入力を盗み見る
answer
の中で引数を出力させることで、テスト入力を盗み見ることができます。
def answer1(n)
p n
end
7問目以降ではこのチートは禁止してあります。
def answer7(n)
p n
end
$stderr
を使ってもダメです。
def answer7(n)
$stderr.puts n.inspect
end
$stdout.write
を上書きしていることに気づけば、いくらでも回避方法があります。たとえば、事前に $stdout.method(:write)
を取り出しておくのが簡単でしょう。
Write = $stdout.method(:write)
def answer7(n)
Write.call(n.inspect + "\n")
end
ほかには、IO.for_fd(1)
を使って$stdout
を開いたり、見たい文字列をraise
の引数として呼び出したりすれば回避できます。あまりRuby環境を汚さない回避方法としては、JS連携を使ってconsole.log
を呼び出すという技もありました。
error_highlightを使う
NoMethodErrorを引き起こすことで、該当行のソースが見えてしまいます。
p func2(1)
secret.rb:6:in `func2': undefined method `upcase' for 1:Integer (NoMethodError)
s.upcase
^^^^^^^
from code.rb:1:in `main'
これは意図しなくても発動してしまうので、対策として、func4
以降ではerror_highlightをわざと止めてあります。error_highlight便利ですね!
answerX
からfuncX
に移譲する
次のようにすれば、func1
の中身を推測しなくてもanswer1
は完全に同じ挙動にできてしまいそうです。
def answer1(...)
func1(...)
end
しかし、これは塞いであります。
--- testing answer1
secret.rb:1:in `func1': Do not use func1 during answer :-) (RuntimeError)
from code.rb:2:in `answer1'
from test1.rb:3:in `[]'
from test1.rb:3:in `block in test1'
from test1.rb:2:in `each'
from test1.rb:2:in `test1'
どうしているかというと、func1
の先頭に次のようなコードを仕込んでありました。
raise "Do not use func1 during answer :-)" if caller.any? { _1.include?("answer") }
つまり、バックトレース中に"answer"を含むメソッド名があったら例外にしています。
ちなみに後で報告されたことですが、この対策はFiberを使うことで回避できました。なるほどなあ。
def proxy1(...)
Fiber.new { func1(...) }.resume
end
def answer1(...)
proxy1(...)
end
チートに使えそうな機能を使う
RubyVM::InstructionSequence.of(method(:func1)).disasm
などをすると func1
のバイトコードが覗けてしまうので、このようなメソッドは大体remove_method
しておきました。TracePoint
は定数を上書きしておきました。
ただ、ObjectSpace.each_object
を対策するのが抜けてました。報告された中で一番豪快なチートは、次のようにすれば正解の定義がすべて抜き出せてしまうというものでした。
ObjectSpace.each_object(String) {|s| puts s if s.start_with?("def func") }
いやー抜けてたなあ。
リバースエンジニアリングへの道(CTFに興味ある人向け)
このパズルはすべてブラウザで動いているので、正解のデータもすべて当然ブラウザ上に入っています。よって、リバースエンジニアリングをすれば理論上はすべてがわかります。
JSのソースコードを見ると/src/app.dat
というファイルを参照していることがわかります。このファイルは、RubyKaigi 1日目のキーノートでも少し出てきたwasi-vfsを使ってwasmファイルに埋め込んであるので、パズルのコードからでも読めます。
p File.binread("/src/app.dat")
これはRubyのバイトコードをダンプしたデータで、RubyVM::InstructionSequence.load_from_binary
を使ってロードすることができます。
このダンプデータは環境依存なので、WasmのRubyでないとload_from_binary
できません。しかし、パズルのWasmではRubyVM::InstructionSequence.load_from_binary
をremove_method
しておいたので、別途wasmtimeなどでwasm32のRubyを動かしてdisasmを見る必要があるでしょう。また、正解のコード部分はAES暗号化されています(パズルではJS連携を使ってWebCrypto APIで復号しています)。腕に覚えがある人は、解読を頑張ってみてください。