LoginSignup
2
1

More than 5 years have passed since last update.

Hack.lu writeup

Posted at

概要

この前Hack.luというCTFに参加したのでそのwrite up(自分で取り組んだ方法のまとめ)です。

二人のチームで参加し以下の問題を解きました。

  • 1. cornelius1
  • 4. simplepdf
  • 6. redacted
  • 8. 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という暗号化方法は現在でも使われている方法で目立った脆弱性はなさそうです。

もう少し詳しくフローを見てみます。

  1. GETリクエストを送る。このときuserをパラメーターとして渡せる。
  2. ["#{渡したuserパラメーター}", "flag:#{ここが分からない}"]というjsonデータを作ります
  3. これをgzipで圧縮します。
  4. AES-CTRで暗号化します。
  5. BASE64でエンコードします。
  6. 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やバイナリ解析が出来るようになりたいものですね…)

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1