Tiger Blog

Firewall を貫くツールを自作してみた

これは 【その2】ドリコム Advent Calendar 2015 24日目の記事です。
23日目の記事は hayato240 さんの二郎を思い浮かべながら、RSpecを学んだことの振り返り です。
【その1】ドリコム Advent Calendar 2015 もあわせてどうぞ。

自己紹介

こんにちは、タイガーです。

中国上海からきてます。ドリコム歴はもう4年目です。
本業はサーバーサイドのエンジニアです。今年は iOS と Android のクライアントの開発も結構やりました。

まえがき

この間 Great Firewall が VPN も遮断するニュースがありまして、ちょっと周りの人とその話をしました。

多分エンジニアの皆さんはご存知と思います。中国にいるならこれを越えないといろいろ不便になります。そのためにいろんなツールも作られています。一番有名のは shadowsocks、見ての通りいつかその存在が消えるかもしれませんね。エンジニアとしてはやはり自分がその仕組を理解すれば一番いいと思います。

本題

Firewall を通過するには、SOCKS5 っていうプロトコルが存在します。具体的な仕様は RFC1928 に定義されています。8ページしかないので、割りとすぐ読み終えます。基本的は SOCKS5 をベースにして作ればいろいろ汎用出来ます。

すでに shadowsocks 大先輩があるので、それを参考にしながら作ってみましょう。言語はもちろん Ruby です。

大体な構造は下図の通り

  • まずはローカルサーバーとリモートサーバー2つの部分があります。
  • ブラウザとかの SOCKS5 をサポートしてるアプリケーションはローカルサーバーに接続します。
  • リモートサーバーは Firewall の外にあるサーバーで実行します。
  • Firewall の外にアクセスする時は ブラウザ → ローカルサーバー → リモートサーバー → 目標サーバー の流れになります。

ローカルサーバー

まず必要なのは並列処理です。ブラウザは同時にたくさんのアクセスがくるので、並列処理ができないと使い物にならないです。今の時代はもちろん Event Driven I/O でやります。Ruby なら EventMachine があります。

とりあえず EventMachine で実装してみました。ちょっと長いです。(´・ω・`)

local.rblink
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
require "eventmachine"

LOCAL_SERVER_HOST = "127.0.0.1"
LOCAL_SERVER_PORT = "8081"
REMOTE_SERVER_HOST = "127.0.0.1"
REMOTE_SERVER_PORT = "8082"

class LocalConnection < EventMachine::Connection
  attr_accessor :server

  def send_encoded_data(data)
    return if data.nil? || data.length == 0
    send_data(data)
  end

  def receive_data(data)
    server.send_data(data)
  end

  def unbind
    server.close_connection_after_writing
  end
end

module LocalServer

  def post_init
    @fiber = Fiber.new do
      greeting
      loop { do_command }
    end
  end

  def receive_data(data)
    if @connection
      @connection.send_encoded_data(data.to_s)
    else
      @data = data
      @fiber = nil if @fiber.resume
    end
  end

  def unbind
    @connection.close_connection if @connection
  end

  private

    # IN
    # +----+----------+----------+
    # |VER | NMETHODS | METHODS  |
    # +----+----------+----------+
    # | 1  |    1     | 1 to 255 |
    # +----+----------+----------+
    #
    # OUT
    # +----+--------+
    # |VER | METHOD |
    # +----+--------+
    # | 1  |   1    |
    # +----+--------+
    def greeting
      ver = @data.unpack("C").first
      clear_data
      if ver == 5
        send_data "\x05\x00"  # NO AUTHENTICATION REQUIRED
      else
        send_data "\x05\xFF"  # NO ACCEPTABLE METHODS
      end
      Fiber.yield
    end

    # IN
    # +----+-----+-------+------+----------+----------+
    # |VER | CMD |  RSV  | ATYP | DST.ADDR | DST.PORT |
    # +----+-----+-------+------+----------+----------+
    # | 1  |  1  | X'00' |  1   | Variable |    2     |
    # +----+-----+-------+------+----------+----------+
    #
    # OUT
    # see the defination of reply_data
    def do_command
      _, cmd, _, atype, addr_length = @data.unpack("C5")
      header_length = 0

      case atype
      when 1, 4  # 1: ipv4, 4 bytes / 4: ipv6, 16 bytes
        ip_length = 4 * atype
        host = IPAddr.ntop @data[4, ip_length]
        port = @data[4 + ip_length, 2].unpack('S>').first
        header_length = ip_length + 6
      when 3     # domain name
        host = @data[5, addr_length]
        port = @data[5 + addr_length, 2].unpack('S>').first
        header_length = addr_length + 7
      else
        panic :address_type_not_supported
      end

      case cmd
      when 1
        send_data reply_data(:success)
        @connection = EventMachine.connect(REMOTE_SERVER_HOST, REMOTE_SERVER_PORT, LocalConnection)
        @connection.server = self
        @connection.send_encoded_data("#{host}:#{port}\n")
        @connection.send_encoded_data(@data[header_length, -1])
        clear_data
        Fiber.yield
      when 2, 3  # bind, udp
        panic :command_not_supported
      else
        panic :command_not_supported
      end
    end

    # +----+-----+-------+------+----------+----------+
    # |VER | REP |  RSV  | ATYP | BND.ADDR | BND.PORT |
    # +----+-----+-------+------+----------+----------+
    # | 1  |  1  | X'00' |  1   | Variable |    2     |
    # +----+-----+-------+------+----------+----------+
    def reply_data(type)
      @replies_hash ||= begin
        {
          success:                    0,
          command_not_supported:      7,
          address_type_not_supported: 8,
        }.map { |k, v| [k, ("\x05#{[v].pack('C')}\x00\x01\x00\x00\x00\x00\x00\x00")] }.to_h
      end
      @replies_hash[type]
    end

    def clear_data
      @data = nil
    end

    def panic(reply_type)
      send_data reply_data(reply_type)
      Fiber.yield true
    end
end

EventMachine.run do
  puts "Start socks5 at #{LOCAL_SERVER_HOST}:#{LOCAL_SERVER_PORT}"
  EventMachine.start_server(LOCAL_SERVER_HOST, LOCAL_SERVER_PORT, LocalServer)
end
  • SOCKS5 の handshake をやるとき連続でデータのやり取りをするから、ここでは Fiber を使えば効率よくかつかっこ良く実装できます。
  • アクセスがきたら、SOCKS のバージョンは5だったら \x05\x00 を返します。(認証いらないから、NMETHODS と METHODS は無視)
  • 次にコマンドのデータがきます。仕様通りホストとポートを解析します。
    • 急いで書いたので、TCP しか実装していません。(´Д`)
  • 解析したホストとポートと転送したいデータをリモートサーバーに送ります。
    • shadowsocks はローカルからリモートへ転送する時も SOCKS5 に従って実装してます。多分リモートサーバーの間に互換性をもたらすためです。こちらはシンプルのため、下記の形でデータを送ります。
    • <HOST>:<PORT>\nDATA...

リモートサーバー

ローカルサーバーに比べたらすごくシンプルなものです。
上記のホストとポートを取り出し、目標サーバーに接続し、データの部分をそのまま送ります。
最後は帰ってきたレスポンスを逆の経路でブラウザに返します。

remote.rblink
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
require "eventmachine"

REMOTE_SERVER_PORT = "8082"

class RemoteConnection < EventMachine::Connection
  attr_accessor :server

  def receive_data(data)
    @server.send_encoded_data(data)
  end

  def unbind
    @server.close_connection_after_writing
  end
end

class RemoteServer < EventMachine::Connection
  def post_init
    @buffer = ""
  end

  def send_encoded_data(data)
    return if data.nil? || data.length == 0
    # TODO: encode data
    send_data(data)
  end

  def receive_data(data)
    # TODO: decode data
    if @buffer
      @buffer << data
      addr, rest = @buffer.split("\n", 2)
      if rest && rest.length > 0
        host, port = addr.split(":")
        port = port.nil? ? 80 : port.to_i
        @connection = EventMachine.connect(host, port, RemoteConnection)
        @connection.server = self
        @connection.send_data(rest) if rest.length > 0
        @buffer = nil
      end
    else
      @connection.send_data(data) if data && data.length > 0
    end
  rescue
    @connection.close_connection if @connection
    close_connection
  end

  def unbind
    @connection.close_connection if @connection
  end
end

EventMachine.run do
  puts "Starting server at 0.0.0.0:#{REMOTE_SERVER_PORT}"
  EventMachine.start_server('0.0.0.0', REMOTE_SERVER_PORT, RemoteServer)
end

使い方

リポジトリはこちらに上げました。socks-tunnel-demo

  • まずは一番上のホストとポートを設定します。
  • ローカルで bundle exec ruby local.rb を実行します。
  • リモートサーバーで bundle exec ruby remote.rb を実行します。
  • ブラウザのプロキシの設定にローカルサーバーを設定します。Mac ならこんな感じです。

これでブラウザはプロキシ経由で外にアクセスできるはずです。

TODO

ここまで作ったものは実際には全く使いものにならないです。orz

何故なら急いで書いたから、肝心な暗号化の部分はまだ実装されていません(TODO が書いてるところ)。 今のローカルからリモートへの通信は Firewall に丸見えです。簡単に探知され、ゲームオーバーになります。

暗号化については連休中で補完できればと思います。本当に申し訳ございません。m(_ _)m

明日

【その2】ドリコム Adevent Calendar 2015 25日目は ericinderbuchtvontokio さんです。