Socks v5 及其相关协议


SOCKS Protocol Version 5

参考自 RFC1928 SOCKS Protocol Version 5

1. 简介

  在愈来愈多的网络系统中,可以使用防火墙将内部网络从外部网络(如因特网)中脱离隔绝。这种防火墙一般以应用层网关的形式工作在两网络之间,并能提供应用层协议的访问;但随着愈来愈常见的、愈来愈复杂的应用层协议的到来,有必要提供一个通用框架使得这些应用层协议能穿透防火墙。

  这个框架(指 Socks)是为了 TCP/UDP 应用程序更方便地使用防火墙服务而设计的。它在概念上是处于应用层与传输层之间的“中介层”,因此其并不提供网络层等的相关服务(如 ICMP)。

2. 现有的协议

  目前,已存在一个 Socks v4 协议,但它为应用程序所提供的防火墙穿透是不安全的。Socks v5 扩展了旧的协议,增加了 UDP 转发、IPv6 等的支持。

  若无特别说明,所有出现在“消息格式表格”中的十进制数字均代表相应的字段的长度(以字节为单位);而某个字段的值以“X’hh’”十六进制的方式进行表示;文字“可变”则表示该字段的(值,或内容)长度是可变长的,或该长度是由数据类型字段决定的。

3. 基于 TCP 的客户端

  (基于TCP的)客户端要与防火墙外的终端进行通信之前,需要先与 Socks 服务器(默认端口:1080)建立一个 TCP 连接。建立连接后:客户端与服务端握手(Negotiation,协商)——即选择认证方式并进行验证,并以此决定接受或拒绝该连接。

  客户端连接到服务器后,需要立即发送“协商版本”与“认证方式”:

VersionN_MethodMethods
1字节1字节1~255字节

  在该版本中,Version 字段总是为 X'05;N_Method 字段表明了 Methods 字段的长度(以字节为单位)。服务器需要在 Methods 字段中选一个方法并返回以下消息给客户端。

VersionMethod
1字节1字节

  若服务器返回的 Method 是 X'FF',则客户端必须关闭该连接(这表明服务端不支持客户端所列出的所有方法)。

  其中,当前已定义的方法有:

  • X'00':无需认证;
  • X'01':GSSAPI;
  • X'02':用户名/密码;
  • X'03' ~ X'7F':由IANA所分配的;
  • X'80' ~ X'FE':为私人方法所保留的;
  • X'FF':没有可支持的方法;

  之后,客户端与服务端将根据所选定的验证方法进入一个“子协商过程(Sub-Negotiation)”。符合标准的 Socks v5 实现必须支持 GSSAPI 方式验证,并应支持用户名/密码方式认证。

4. 请求

  一旦子协商过程结束后,客户端就要发送详细的请求信息。

  Socks v5 的请求格式如下:

VersionCommandReserveAddressTypeDestinationAddressDestinationPort
1字节1字节1字节,总为 X'00'1字节可变长度2字节
  其中:
  • Version:协议版本,总为 X'05'
  • Command:命令
    • Connect:X'01'
    • Bind:X'02'
    • UDP Associate:UDP转发,值为 X'03'
  • Reserve:保留,总为 X'00'
  • AddressType:地址类型
    • IPv4:X'01'
    • 域名:X'03'
    • IPv6:X'04'
  • DestinationAddress:目的地址;
  • DestinationPort:目的端口(网络序)。

  Socks 服务器会根据源地址和目的地址来分析请求,然后根据请求类型返回一个或多个应答。

5. 地址

  AddressType 字段中描述了地址字段(DestinationAddress 或下文的 BindAddress)的地址类型:

  • X'01':IPv4地址,4字节长;
  • X'03':域名,地址字段中的第一字节是以字节为单位指明该域名的长度,末尾没有 NULL;
  • X'04':IPv6地址,16字节长。

6. 应答

  一旦建立了与 Socks 服务器的连接,并且完成了认证方式的协商过程,客户端将会发送一个 Socks 请求信息给服务器。

  服务器将会根据请求,以如下格式返回:

VersionResponseReserveAddressTypeBindAddressBindPort
1字节1字节1字节,总为 X'00'1字节可变长度2字节
  其中:
  • Version:协议版本,总为 X'05'
  • Response:应答字段
    • X'00':成功
    • X'01':Socks 服务器请求失败
    • X'02':现有的规则不允许的连接
    • X'03':网络不可达
    • X'04':主机不可达
    • X'05':连接被拒
    • X'06':TTL 超时
    • X'07':不支持的命令(Command)
    • X'08':不支持的地址类型(AddressType)
    • X'09' ~ X'FF':未定义
  • Reserve:保留,总为 X'00'
  • AddressType:地址类型
    • IPv4:X'01'
    • 域名:X'03'
    • IPv6:X'04'
  • BindAddress:服务器绑定的地址;
  • BindPort:服务器绑定的端口(网络序);

6.1 CONNECT

  在一个 Connect 请求的应答中,BindAddress 是 Socks 服务器自身的 IP 地址(可能与客户端连接到的 Socks 服务器的不同,因为其可能有多个 IP 地址);而 BindPort 则是 Socks 服务器用以连接目标机器的端口号。

  注:客户端创建一个 socket 连接到 Socks 服务器,Socks 服务器创建一个 socket 连接到目的机器。这个 BindPort 指的就是 Socks 服务器创建的那个 socket 的端口号。

6.2 BIND

  有时,服务器会要求客户端接受来自服务器自己的连接(如 FTP,其使用另一个从服务器到客户端的连接来接收数据),Bind 请求就是用于如此目的。

  在一个 Bind 请求的操作过程中,Socks 服务器要发送两个应答给客户端:

  1. 当 Socks 服务器建立并绑定了一个新的 socket 时发送第一个应答:
    • BindAddress 为该 socket 的地址;
    • BindPort 为该 socket 所绑定的端口号。
  2. 当该 socket 接受(accept)了一个(所期待的)连接之后,发送第二个应答:
    • BindAddress 为连接的地址;
    • BindPort 为连接的端口号。

  第二个应答可能不太理解,原文如下:The second reply occurs only after the anticipated incoming connection succeeds or fails. In the second reply, the BindAddress and BindAddress fields contain the address and port number of the connecting host.

  accept() 后的 socket 就是远程主机的 socket。BindAddress 和 BindPort 字段就是远程主机的地址和端口号。

6.3 UDP ASSOCIATE

  UDP Assocaite 请求是用来转发 UDP 数据报。DestinationAddress 与 DestinationPort 是客户端专门用于转发 UDP 数据报的、基于 UDP 的 socket 的地址及端口号。

  注:客户端与服务器都使用基于 UDP 的 socket 来转发 UDP 数据报。请求中的 Destination 填充客户端的地址端口号,而应答中的 Bind 填充服务器的地址端口号——这样服务器才能确保其分配的 UDP socket 仅为对应的客户端提供中转服务。但考虑到 NAT 地址转换问题:客户端并不能知道服务器看到的是否是客户端自己用来发送接收 UDP 数据报的地址和端口号(因为可能被 NAT 转换了)。针对这种情况,就建议客户端使用 0 填充 DestinationAddress 和 DestinationPort 字段。这样,服务器就不会限制客户端了(服务器可以根据先前的 UDP Associate 请求中的地址和端口号字段来判断是否是非法的客户端)。

  在应答中,BindAddress 与 BindPort 分别代表 Socks 服务器用以转发 UDP 数据报的 socket 的地址和端口号(通过新的 UDP 套接字来转发,而不是该 TCP 连接;当客户端与 Socks 服务器的 TCP 连接中断时,该 UDP 套接字也要关闭)。

6.4 应答处理

  当 Response 的值X'00' 时,Socks 服务器必须在发完这条应答后的一小段时间内(在发现错误后的 10 秒内)关闭该 TCP 连接;如果 Response 为 X'00',客户端就可以开始发送数据了。

7. 基于 UDP 的客户端

  在 UDP Associate 请求的应答中,Socks 服务器会返回服务器所监听的地址及端口号(UDP 端口,非TCP)。客户端必须将待转发的 UDP 数据报发送到这个基于 UDP 的 socket。

  注:上述也有提到,Socks 服务器使用额外的、基于 UDP 的 socket 来转发客户端的 UDP 数据报。

  每一个 UDP 数据报都要根据下面的“UDP 请求头”格式重新封装:

  注:UDP 由 8 字节的固定“报头”和紧跟随后的“有效载荷”构成。而下述的“Data”字段就是原始 UDP 数据报的“有效载荷”部分。

ReserveFragmentAddressTypeDestinationAddressDestinationPortData
2字节,总为 X'0000'1字节1字节可变长度2字节可变长度

  在 UDP 请求头中:

  • Reserver:保留,总为 X'0000'
  • Fragment:分片号(不分片则为 X'00',否则代表第几片);
  • AddressType:地址类型
    • IPv4:X'01'
    • 域名:X'03'
    • IPv6:X'04'
  • DestinationAddress:目的地址;
  • DestinationPort:目的端口号(网络序);
  • Data:用户数据。

  当转发客户端的 UDP 数据报时,并不会告诉客户端是否已发送成功、发送失败等消息;不能转发就直接抛弃。当收到其它远程主机的 UDP 数据报时,必须按照上述的请求头重新封装,然后再转发给客户端。

  注:UDP 转发时,服务器要记住客户端的源地址和源端口号(客户端自己)以及目标主机的地址和端口号(就是客户端想要发给谁,谁就是那个目的地址和目的端口号)。当服务器收到非客户端的 UDP 数据报时,要判断是否是回复给客户端的(通过之前所记住的源地址源端口号以及目的地址目的端口号;如果该 UDP 数据报的源地址源端口号与已记住的目的地址目的端口号相同,则该数据报是要转发给客户端的),若是,则转发给客户端;否则直接丢弃。

  UDP 的分片功能是可选的,其中的 Fragment 就表明这些数据报是否是某些分片中的一片。如果您不想实现该功能,则所有被分片的 UDP 数据报(即 Fragment 字段均不为 X'00')都必须丢弃。否则,Fragment 的值就代表某个分片在这堆分片中的位置(1~127)。实现并接收这些分片时要注意:需要为这堆分片提供一个“重组队列(用于缓冲或合并数据报)”和一个“计时器(不少于 5 秒)”。当发生以下情况时:

  • 计时器超时了
  • 收到了新的数据报的 Fragment 值比已接收的所有数据报最大的 Fragment 值都要小(即这可能是新的一堆分片中的一片或者是数据报乱序到达了)

  都要将重组队列的数据进行合并,并清空重新初始化;计时器也要重新开始计时。因此,如无必要都不应该使用分片功能。

  因为这些被转发的 UDP 数据报都需要重新封装。所以在 Socks 服务器中,UDP 数据报的缓存大小肯定是比正常的要小的。在设计、编程实现的时候需要注意该问题。

8. 安全性

  本文介绍了一种为应用层的应用程序穿透防火墙的协议。但它的安全性高度依赖于文中所提及的“认证方法”以及“特定的封装”,在实际应用中,应充分考虑这些问题。
  请求、应答等数据是可以按照所选定的方法(认证时确定的,即服务器回复的 Method 字段)进行特殊的封装的。这些封装一般是为了检查完整性以及/或者提高安全性。

9. 参考

  [1] Koblas, D., “SOCKS”, Proceedings: 1992 Usenix Security Symposium.



GSS-API Authentication for SOCKS V5

参考自 RFC1961 GSS-API Authentication Method for SOCKS Version 5

  待更新。



Username/Password Authentication for SOCKS V5

参考自 RFC1929 Username/Password Authentication for SOCKS V5

1. 简介

  Socks v5 协议规范了一个在最开始的协商过程中指定身份验证方法的通用框架。本文档则介绍其中一种“子协商”协议(方法)。

2. 协商

  一旦客户端选择了用户名/密码认证方法,而服务器也接受使用该方法,则用户名/密码子协商过程开始。首先,客户端发送一个用户名/密码请求:

VersionUN_LengthUsernamePW_LengthPassword
1字节1字节1~255字节1字节1~255字节

  其中:

  • Version:版本(非 Socks 版本),当前为 X'01'
  • UN_Length:用户名长度,即 Username 字段的长度(以字节为单位);
  • Username:用户名;
  • PW_Length:密码长度,即 Password 字段的长度(以字节为单位);
  • Password:密码。

  服务器收到后则对该请求进行验证,并返回以下应答:

VersionStatus
1字节1字节

  其中:

  • Version:版本(非 Socks 版本),当前为 X'01'
  • Status:成功则为 X'00',否则为 X'00'(此时必须关闭连接)。

3. 安全性

  本文档介绍了为 Socks 协议提供的身份验证服务的子协商协议(方法)。由于用户名密码是明文发送的,所以在可能或已被监听的环境中,不要用此协议(方法)。



Socks 服务器参考实现

import asyncio
import datetime
import ipaddress
import struct

VERSION, ADDRESS = 0x05, ("localhost", 1080)

def _now() -> str:
    return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")

def _close_stream(r, w):
    r.feed_eof(), w.close()

async def handle(c_reader: asyncio.StreamReader, c_writer: asyncio.StreamWriter):
    c_address = c_writer.get_extra_info("peer"+"name")

    def _on_auth_failed(msg=None):
        c_writer.write(struct.pack("!BB", VERSION, 0xFF))
        _close_stream(c_reader, c_writer)
        if msg: print(_now(), msg)

    def _on_request_failed(rep, msg=None):
        c_writer.write(struct.pack(
            "!BBBBIH", VERSION, rep, 0x00, 0x01, 0x00, 0x00)) # noqa
        _close_stream(c_reader, c_writer)
        if msg: print(_now(), msg)

    ver, n = struct.unpack(
        "!BB", await c_reader.readexactly(2))
    if ver!=VERSION:
        return _on_auth_failed(f"version invalid")

    methods = set(await c_reader.readexactly(n))
    if 0x00 not in methods:
        return _on_auth_failed("method not supported")

    # step 1, authenticated successfully.
    c_writer.write(struct.pack("!BB", VERSION, 0x00))

    ver, cmd, _, atyp = struct.unpack(
        "!BBBB", await c_reader.readexactly(4))
    if ver!=VERSION:
        return _on_request_failed(0xFF, f"version invalid")
    if cmd!=0x01: # connect.
        return _on_request_failed(0x07, f"command not supported")

    r_address, r_dns = None, None
    r_reader, r_writer = None, None

    if atyp==0x03: # dns.
        n = int.from_bytes(await c_reader.readexactly(1), byteorder="big")
        dns, port = struct.unpack(
            f"!{n}sH", await c_reader.readexactly(n+2))
        r_dns = dns.decode()
        r_address = (r_dns, port)
    elif atyp==0x01: # ipv4.
        ip, port = struct.unpack(
            "!IH", await c_reader.readexactly(4+2))
        r_address = (ipaddress.IPv4Address(ip), port)
    elif atyp==0x04: # ipv6.
        ip, port = struct.unpack(
            "!16sH", await c_reader.readexactly(16+2))
        r_address = (ipaddress.IPv6Address(ip), port)
    else:
        return _on_request_failed(0x08, "address type not supported")

    try:
        r_reader, r_writer = await asyncio.wait_for(
            asyncio.open_connection(*r_address), timeout=60)
    except TimeoutError:
        return _on_request_failed(0x06, "TTL expired")
    except Exception as e:
        # TODO: sometime raises
        #   `WinError 121 The semaphore timeout period has expired`
        return _on_request_failed(0x05,
            "connection refused, target {}:{}, because {}".format(*r_address, e))

    # step 2, successfully.
    c_writer.write(struct.pack(
        "!BBBBIH", VERSION, 0x00, 0, 0x01, 0, 0)) # noqa
    print("{} connection made, from {}:{} to {}:{}{}".format(
        _now(), *c_address, *r_address, f"({r_dns})" if atyp==0x03 else ""))

    def _should_close():
        return (c_writer.is_closing() or c_reader.at_eof()
                or r_writer.is_closing() or r_reader.at_eof())

    switched = { c_writer:0, r_writer:0, }
    async def _echo(f, t):
        try:
            while not _should_close():
                data = await f.read(2**16)
                if not t.is_closing():
                    t.write(data)
                    await t.drain()
                    switched[t] += len(data)
        except (ConnectionResetError, Exception,):
            # TODO: sometime raises `TypeError: catching classes
            #   that do not inherit from BaseException is not allowed`
            pass
        finally:
            _close_stream(f, t)

    await asyncio.gather(asyncio.create_task(_echo(c_reader, r_writer)),
                         asyncio.create_task(_echo(r_reader, c_writer)))
    print("{} connection lost, from {}:{}, switched {}".format(
        _now(), *c_address, tuple(switched.values()),))

async def main():
    server = await asyncio.start_server(handle, *ADDRESS)
    async with server:
        await server.serve_forever()

if __name__=="__main__":
    asyncio.run(main())
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值