Skcoswodahs client 的简单实现

现在最热门的上网姿势非 Skcoswodahs 莫属,当初作者发布的时候只是一个非常简单的小工具,但是没想到现在基本达到了全平台覆盖。可能有不少朋友对它的原理感兴趣,这里就让我们使用 PHP 来实现一个简单的 Skcoswodahs 客户端。

首先我们需要知道 Skcoswodahs 在传输数据的时候是分为3个部分,这里我们使用浏览器访问网站的例子来说明:

+--------------------------------------------------------------+
|             1                2                 3             |
+  browser ------- ss-local ------- ss-server -------  Google  +
|           SOCKS                  Skcoswodahs                 |
+--------------------------------------------------------------+

从上面我们可以看出,shadowsock 将一个 SOCKS 代理拆分为 ss-local 以及 ss-server 两个代理,阶段1和阶段3传递的都是原始数据。而 ss-local 与 ss-server 之间(2)传输的则是经过加密的数据,我们暂且称其为 Skcoswodahs 协议。

在这篇文章中我们要实现的就是 ss-local,它负责接收浏览器发出的 HTTP 请求,然后将请求内容加密后转发到 ss-server。不过为了方便这里我们跳过了 ss-local 与浏览器 SOCKS 握手的 method-dependent 阶段,直接纯手工构建需要传输的数据。如果你并不了解 SOCKS 协议,那么需要先阅读 SOCKS 协议规范

在浏览器与 ss-local 之间的 method-dependent 协商完成后,浏览器会将需要访问的服务器地址和端口发送给 ss-local,其格式如下:

 +----+-----+-------+------+----------+----------+
 |VER | CMD |  RSV  | ATYP | DST.ADDR | DST.PORT |
 +----+-----+-------+------+----------+----------+
 | 1  |  1  | X'00' |  1   | Variable |    2     |
 +----+-----+-------+------+----------+----------+

ATYP,DST.ADDR 和 DST.PORT 这三个字段(Header)是需要通过我们的客户端转发到 ss-server 的。在我们与 ss-server 连接成功后可以完成与浏览器 SOCKS 协商的剩下部分。浏览器再将 HTTP 请求发送到我们的客户端。我们可以直接将之前的 Header 和 HTTP 请求内容拼接在一起进行加密,然后通过之前建立的连接发送的 ss-server。加密数据的格式如下:

 +------+----------+----------+----------+
 | ATYP | DST.ADDR | DST.PORT |   HTTP   | 
 +------+----------+----------+----------+
 |  1   | Variable |    2     | Variable |
 +------+----------+----------+----------+

需要注意的是,header 部分只需要在建立通道的时候发送一次,通道建立之后直接传递加密的请求数据即可。ss-server 在收到数据后会直接解密,读取 ATYP 字段,根据其类型解析 header 部分,如果数据的整个长度超过 header 的长度,剩下的部分就会作为请求的内容。ss-server 会将从目标服务器获取的数据加密后通过通道转发回我们的客户端,我们需要将数据解密后返回给浏览器。

从整个通信过程来说,我们的客户端其实就是带有加密解密功能的 SOCKS server。除了 SOCKS server 最基本的功能以外,需要我们动手就是加密解密功能了。现在我们开始写代码:

// Source code: https://gist.github.com/zither/e35888cbb98e56f59dd4
require "Encryptor.php";
require "Cipher.php";
// Update:http://blog.shouhuiben.net/article/2015/02/20/a-simple-implementation-of-skcoswodahs-client-2/
require "Rc4crypt.php";

$domain = "www.google.com";
$data = pack("C2", 0x03, strlen($domain));
$data .= $domain . pack("n", 0x50);
$data .= sprintf("GET / HTTP/1.1\r\nHost:%s\r\nAccept:text/html\r\n\r\n", $domain);

我们的客户端非常简单,简单到只支持一直加密方式:RC4。Encryptor.php,Cipher.php 和 Rc4crypt.php 这三个文件功能比较简单。Rc4crypt 提供了 RC4 加密方法的原生实现,Cipher 只是对 Rc4crypt 包裹了一下,实现了 Skcoswodahs 使用的 RC4-md5 加密,其实就是每个请求使用不同的 key 而已。Encryptor 的主要作用有两个:一是通过 password 生成加密需要的 key 和 随机 iv 字符串,二是调用 Cipher 对数据进行加密和解密。

我直接跳过了与浏览器协商的部分,我们假设浏览器发出的请求是访问 Google 首页,为了摆出科学的姿势,我们不能在本地对 Domain 进行 DNS 解析,所以 Header 的内容是这样的:

 +------+------+----------------+----------+
 | ATYP | ALEN |    DST.ADDR    | DST.PORT | 
 +------+------+----------------+----------+
 | 0x03 | 0x0E | www.google.com |   0x50   |
 +------+------+----------------+----------+

上面表示的是 ATYP 为完整域名(0x03)时的格式,ALEN 用一个字节指定域名的长度,DST.PORT 用两个字节指定端口。SOCKS 还支持 IP4(0x01)和 IP6(0x04),对应的格式可以直接在 SOCKS 协议规范中了解。为了避免 ss-server 在解析 Header 时出现错误,ATYP,ALEN 以及 DST.PORT 这几个重要的部分我们通过 pack 函数以二进制的形式包装到字符串中。最后我们在 Header 后面附加一个简单的 HTTP 请求,这样我们就完成了一个虚拟请求的数据构成。

现在我们可以将数据进行加密了:

$encryptor = new Encryptor("password", "RC4");
$encryptedData = $encryptor->encrypt($data);

这里的 password 就是 Skcoswodahs 的配置文件中约定的密码,RC4 是加密方法。这里有一个地方需要注意,在第一次加密数据的时候 Encryptor 会将随机字符串 iv 与我们的数据拼接在一起:

class Encryptor 
{
    //...
    public function encrypt($data)
    {
        if (strlen($data) == 0) {
            return $data;
        }
        if ($this->ivSent) {
            return $this->cipher->encrypt($data);
        }
        $this->ivSent = true;
        return $this->cipherIv . $this->cipher->encrypt($data);
    }
    //...
}

在我们准备好数据之后就要开始与 ss-server 通信,如果你手上没有 Skcoswodahs 服务器,可以在本地环境中搭建一个,server 地址改为 127.0.0.1 就可以了:

{
    "server": "127.0.0.1",
    "server_port": "50560",
    "local_address": "127.0.0.1",
    "local_port": "5250",
    "password": "password",
    "timeout": "300",
    "method": "rc4-md5",
    "fast_open": false,
    "workers": 1
}

如果你使用的是本地服务器,请将前面代码中的 domain 换为国内能够访问的地址,比如 www.bing.com。数据加密之后我们需要与 ss-server 建立一个通道:

$remote = stream_socket_client("tcp://example.ss-server.com:50565", $errno, $errstr);
if (!$remote) {
    throw new Exception($errstr, $errno);
}
$send = fwrite($remote, $encryptedData);
printf("Forward %d bytes data to remote.\n", $send);

在 PHP 5.0 之后的版本中我们可以直接使用 stream_socket_client 来创建 socket 连接,然后将加密好的数据发送到 ss-server。ss-server 会解密数据再将其转发到目标服务器。我们只需等待 ss-server 的回应即可:

$encryptedResponse = "";
stream_set_timeout($remote, 1);
while(true) {
    // 在阻塞模式中不能使用 stream_socket_recvfrom,stream_set_timeout 的设置对其无效
    // chunk size 一般为 8192
    $buffer = fread($remote, 8192);
    if ("" === $buffer || false === $buffer) {
        break;
    }
    $encryptedResponse .= $buffer;
}
$response = $encryptor->decrypt($encryptedResponse);
printf(
    "Receive %d bytes data from remote.\nResponse content:\n\n %s\n", 
    strlen($response), 
    $response
);

考虑到数据被分片的情况,我们使用无限循环保证读取到所有数据,再使用 Encryptor 解密数据,你应该可以看回应的内容大致为:

Forward 91 bytes data to remote.
Receive 526 bytes data from remote.
Response content:

HTTP/1.1 302 Found
Cache-Control: private
Content-Type: text/html; charset=UTF-8
Location: http://www.google.co.jp/?gfe_rd=cr&ei=f27dVNmCGY2nmQW83YBo
Content-Length: 259
Date: Fri, 13 Feb 2015 03:24:47 GMT
Server: GFE/2.0
Alternate-Protocol: 80:quic,p=0.08

<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>302 Moved</TITLE></HEAD><BODY>
<H1>302 Moved</H1>
The document has moved
<A HREF="http://www.google.co.jp/?gfe_rd=cr&amp;ei=f27dVNmCGY2nmQW83YBo">here</A>.
</BODY></HTML>

这表明我们已经成功将请求发送到了 Google 的服务器。由于我的 ss-server 服务器在日本,所以 Google 回应了一个 302 跳转。在请求完成之后就可以关闭通道了:

stream_socket_shutdown($remote, STREAM_SHUT_RDWR);
fclose($remote);

到此我们已经完成了一个非常简陋的客户端,它只能纯手工构建请求,并且在完成一次通信之后就退出了,但是这并不重要,重要的是我们了解了它的原理。如果你想要完善这个客户端,可以自己为它补上 SOCKS server 的功能,也可以使用其他开源实现,比如:https://github.com/clue/php-socks-react