概要
この前Hack.luというCTFに参加したのでそのwrite up(自分で取り組んだ方法のまとめ)です。
二人のチームで参加し以下の問題を解きました。
-
- cornelius1
-
- simplepdf
-
- redacted
-
- cryptolocker
1. cornelius1
URLとrubyで書かれたサーバーのコードが渡されます。
require 'openssl'
require 'webrick'
require 'base64'
require 'json'
require 'zlib'
# require 'pry'
def encrypt(data)
cipher = OpenSSL::Cipher::AES.new(128, :CTR)
cipher.encrypt
key = cipher.random_key
iv = cipher.random_iv
cipher.auth_data = ""
encrypted = cipher.update(data) + cipher.final
return encrypted
end
def get_auth(user)
data = [user, "flag:"+File.read("flag.key").strip]
json = JSON.dump(data)
zip = Zlib.deflate(json)
return Base64.strict_encode64(encrypt(zip))
end
class Srv < WEBrick::HTTPServlet::AbstractServlet
def do_GET(req,resp)
user = req.query["user"] || "fnord"
resp.body = "Hallo #{user}"
resp.status = 200
puts get_auth(user).inspect
cookie = WEBrick::Cookie.new("auth", get_auth(user))
resp.cookies << cookie
return resp
end
end
srv = WEBrick::HTTPServer.new({Port: 12336})
srv.mount "/",Srv
srv.start
コードを読むとGETリクエストを送るとflagを暗号化されたものをcookieにつけて返してくれます。
なので、これを復号化したら良さそうですが、調べたところここで使われているAES-CTRという暗号化方法は現在でも使われている方法で目立った脆弱性はなさそうです。
もう少し詳しくフローを見てみます。
- GETリクエストを送る。このときuserをパラメーターとして渡せる。
- ["#{渡したuserパラメーター}", "flag:#{ここが分からない}"]というjsonデータを作ります
- これをgzipで圧縮します。
- AES-CTRで暗号化します。
- BASE64でエンコードします。
- cookieにこれをつけてレスポンスを返します。
base64とzipはどちらも簡単にエンコード、デコードできるので、やはりAESが問題となります。
- userパラメーターを指定できる。
- 圧縮している
という点が怪しいですね。
調べたところ、AES-CTRはブロック暗号ですが、最後のブロックは可変長で良いらしいです。つまりこの暗号化でバイト数が変わらないのです!
試しに、user=flag:とつけて帰ってきたcoockieを見てみたらuser=hogesと送ったときと比べて数バイト小さくなっていました。
なので、以下の方法でflagで手に入りそうです。
- flag:[a-zA-z0-9]を試してレスポンスのサイズが小さくなる文字を見つる。
- (例としてそれがaだとすると)flag:aをfixして、次の文字列を探す。
- これを繰り返す。
以下のコードで解けました。
require 'openssl'
require 'net/http'
require 'uri'
require 'json'
require 'base64'
require 'zlib'
class BruteForce
def initialize
base = 'https://cthulhu.fluxfingers.net:1505/'
@uri = URI.parse(base)
end
def send(q)
# puts @uri.request_uri
res = Net::HTTP.start(@uri.host, @uri.port, :use_ssl => true, :verify_mode => OpenSSL::SSL::VERIFY_NONE) do |http|
http.get("/?user=#{q}")
end
raise "user name is werid: body=#{res.body}, q=#{q}" unless res.body.include?(q.gsub('+', ' '))
cookie = res.get_fields('Set-Cookie')[0]
# puts cookie
auth = cookie[5..-1]
return Base64::strict_decode64 auth
end
def attack_one(known)
min_size = send(known + 'A').bytesize
# min_alpha = '0'
[*('B'..'Z'), *('a'..'z'), *('0'..'9'), *('~,[]/:@=$_-.!*()\'/?'.split(''))].each do |alpha|
enc_size = send(known + alpha).bytesize
puts "#{known + alpha} => #{enc_size}"
if enc_size == min_size
next
elsif enc_size < min_size
return alpha
elsif enc_size > min_size
return 'A'
end
end
raise 'no alpha find'
end
def search
null_size = send('').bytesize
flag_size = null_size - 20 # this is minimam
puts "null_size = #{null_size}"
puts "flag_size = #{flag_size}"
# query = 'flag:Mu7a*de'
# query = 'flag:Mu7aede'
query = 'flag:Mu7a'
# query = 'flag:'
offset = query.bytesize - 'flag:'.bytesize
query = query[5..-1]
offset.upto(flag_size) do |i|
res = attack_one(query)
# res = attack_one(query)
query << res
puts
puts "#{i}: query=#{query}"
puts '-----------------------------------------------'
end
end
end
実は、gzip圧縮で使われるアルゴリズムの影響か、途中で減らない場合があるのですが、その場合、送るクエリを"flag:Mu7a"から"Mu7a"とするようにしたら減りました。
総当りに近く、また終わりが分からないという問題があり、あんまりきれいな解き方ではないように思いますがどうなんでしょうね…
4. simplepdf
一つのpdfが渡されます(30MB)。
macのプレビューだとtest123と表示されるだけで\(^o^)/オワタて感じなのですが、相方のubuntuだとリンクがありクリックすると10000.pdfというのが出来ます。これにもリンクがあり、クリックすると9999.pdfが出てきます。
すぐ予想が出来るようにこれを0.pdfまで行うのでしょう。
以下のようなコードで出来ました。
require 'origami'
require 'stringio'
io = StringIO.new('', 'r+')
start = 10000
pdf = Origami::PDF.read("./ruby#{start}.pdf")
start.downto(0) do |i|
puts "execute #{i}"
obj = pdf.get_object(6)
io = StringIO.new(obj.data, 'r+')
pdf = Origami::PDF.read(io)
io.close
pdf.save("ruby#{i}.pdf") if i % 20 == 0
pdf.save("ruby#{i}.pdf") if i < 20
end
相方はpythonで組もうとしていましたが、ライブラリの問題で途中でプログラムが落ちるという問題に直面したのでrubyで書き直してみました。
実はこれ、pdfdetchというコマンドを使えば、shellscriptでも書けるのですが、いちいちフィアル書き込みが発生してしまうので、このようなプログラムを作りました。
6. redacted
sshの秘密鍵、ユーザー名、ホストアドレスが渡されます。
しかし、秘密鍵を見てみると以下のように壊れていることがわかります。
-----BEGIN RSA PRIVATE KEY-----
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/0qc146UXXbxxxxxxxxxxxxxxWe9
AR1kOzxxxxxxxxxxxxxxxxxxxxxxxMSSCxxxxxxxxxxxxxxxx8cCovIcAOZxFEaF
cja1wxEG5MHT7lvXx4U0Kq22p9F2337ct84deN/pkoV+GjRzB1YYbKTCAN7CqX8z
s2x4n9e7WGb71o6D2CPq5kyeLXQPLwnQODs51RqusZCFjoo7atnLq42TWqG9AdHL
...
どうやらこの秘密鍵を復元したらいいようです。
RSAは公開鍵暗号方式の一つで2つの素数p,qと正整数 e、秘密鍵dが全てです。
おそらく、大事な部分は壊れていないと予想されます。
RSAのPEMファイルの規約を探してみます。
RSA 秘密鍵/公開鍵ファイルのフォーマットこのサイトに詳しく載っていました。
16進数をひたすら読んでいきます。0x0281や0x0282がID(一つの区切り)となっている可能性が高いことが分かってきます。
案の定p,q,d,eが残ったままです。
これで必要となる情報がわかったので複合できますが、手動でやるのは間違えそうですし、q^-1 mod p
の計算方法が分からないので、適当なライブラリを使います。
require 'openssl'
rsa = OpenSSL::PKey::RSA.new()
rsa.e = 65537
d = "0x305b82...".hex
rsa.d = d
p = "0x00e4dd...".hex
rsa.p = p
q = "0x00dee5...".hex
rsa.q = q
puts rsa.d == d
puts rsa.p == p
puts rsa.q == q
rsa.dmp1 = d % (p - 1)
rsa.dmq1 = d % (q - 1)
rsa.n = p * q
bnq = OpenSSL::BN.new("00dee5...", 16) # q ^ -1
rsa.iqmp = bnq.mod_inverse(p)
# rsa = OpenSSL::PKey::RSA.new(rsa)
puts rsa.to_text
File.open("private_key.pem", "w") do |f|
f.write(rsa.export())
end
8. cryptolocker
これは相方が解いてくれたので詳しくは説明しませんが、以下のような問題でした。
odtファイルを2文字のアルファベットをsha256でハッシュ化してそれをkeyとしてAES-CBCで暗号化します。これを4回繰り返します。
これを復号化する問題です。
64^8は計算出来ないけど、一回ごとの暗号化で正しいことが確かめれれば、64^2*4となり、これなら解けそうです。
AES-CBCはブロック暗号で、今回16バイトごとにブロック化します。この時keyとivと呼ばれる乱数を使います。暗号文にiv(16バイト)を追加したものが暗号化されたあとに残るものです。keyは上記で言ったように2文字をsha256でハッシュ化したものです。ivは実行時に決まる乱数です。
ブロック暗号なので、バイト数が16の倍数でないとだめなのですが、暗号化しているコードを読むと、なぜか32バイトの倍数でパディングしています。
なので暗号化したあとは32*n+16バイトとなり、次の暗号化で必ず16バイトパディングされます。
各復号化の段階で最後の16バイトがパディング文字列とマッチしているか確認すればいいのでkeyの総当りできます。
最後はパディングされていか分からないので、odtファイルのヘッダーとマッチするか確認してkeyを見つけます。
最後に
以上write upでした。
24時間かけて4問しか解けず、なかなかハードなCTFでしたが、その分やりがいがありました。
特に一番最初に取り組んだ1番がなかなか解けず、最後に解けたときは感動モノでした。
(pwnやバイナリ解析が出来るようになりたいものですね…)