使用 ssl 模块进行 HTTPS 代理隧道传输

13

我想手动(使用socketssl模块)通过使用HTTPS的代理进行HTTPS请求。

我可以很好地执行初始的CONNECT交换:

import ssl, socket

PROXY_ADDR = ("proxy-addr", 443)
CONNECT = "CONNECT example.com:443 HTTP/1.1\r\n\r\n"

sock = socket.create_connection(PROXY_ADDR)
sock = ssl.wrap_socket(sock)
sock.sendall(CONNECT)
s = ""
while s[-4:] != "\r\n\r\n":
    s += sock.recv(1)
print repr(s)

以上代码打印了HTTP/1.1 200 Connection established和一些标头,这是我所期望的。现在我应该准备好发出请求了,例如:

sock.sendall("GET / HTTP/1.1\r\n\r\n")

但是上述代码返回

<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>400 Bad Request</title>
</head><body>
<h1>Bad Request</h1>
<p>Your browser sent a request that this server could not understand.<br />
Reason: You're speaking plain HTTP to an SSL-enabled server port.<br />
Instead use the HTTPS scheme to access this URL, please.<br />
</body></html>

这也是有道理的,因为我仍然需要与我正在进行隧道传输的example.com服务器进行SSL握手。但是,如果我不立即发送GET请求,而是说

sock = ssl.wrap_socket(sock)

尝试与远程服务器握手时,我遇到了异常:

Traceback (most recent call last):
  File "so_test.py", line 18, in <module>
    ssl.wrap_socket(sock)
  File "/usr/lib/python2.6/ssl.py", line 350, in wrap_socket
    suppress_ragged_eofs=suppress_ragged_eofs)
  File "/usr/lib/python2.6/ssl.py", line 118, in __init__
    self.do_handshake()
  File "/usr/lib/python2.6/ssl.py", line 293, in do_handshake
    self._sslobj.do_handshake()
ssl.SSLError: [Errno 1] _ssl.c:480: error:140770FC:SSL routines:SSL23_GET_SERVER_HELLO:unknown protocol

那么我该如何与远程服务器example.com进行SSL握手呢?

编辑:我很确定在第二次调用wrap_socket之前没有可用的额外数据,因为调用sock.recv(1)会无限期地阻塞。


我的粗略猜测是ssl.wrap_socket关心套接字连接状态。通常你会创建套接字,然后包装它,然后连接。在这里,你先创建套接字,连接,然后再包装。也许ssl只是被已经连接的底层套接字状态所困惑了。https://github.com/kennethreitz/requests/blob/598f977df4f52b1d778a40cf4243dd93e486a58a/requests/packages/urllib3/contrib/pyopenssl.py#L333 - Dima Tisnek
嘿,你有什么运气吗?我被卡在同样的问题上,但也没有找到任何东西... - 02strich
5个回答

9
如果将CONNECT字符串改写如下,则此方法应该可行:
CONNECT = "CONNECT %s:%s HTTP/1.0\r\nConnection: close\r\n\r\n" % (server, port)

不确定为什么这样会起作用,但可能与我使用的代理有关。以下是示例代码:

from OpenSSL import SSL
import socket

def verify_cb(conn, cert, errun, depth, ok):
        return True

server = 'mail.google.com'
port = 443
PROXY_ADDR = ("proxy.example.com", 3128)
CONNECT = "CONNECT %s:%s HTTP/1.0\r\nConnection: close\r\n\r\n" % (server, port)

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(PROXY_ADDR)
s.send(CONNECT)
print s.recv(4096)      

ctx = SSL.Context(SSL.SSLv23_METHOD)
ctx.set_verify(SSL.VERIFY_PEER, verify_cb)
ss = SSL.Connection(ctx, s)

ss.set_connect_state()
ss.do_handshake()
cert = ss.get_peer_certificate()
print cert.get_subject()
ss.shutdown()
ss.close()

请注意套接字首先被打开,然后将打开的套接字放入SSL上下文中。然后我手动初始化SSL握手。输出如下:

HTTP/1.1 200 Connection established

<X509Name object '/C=US/ST=California/L=Mountain View/O=Google Inc/CN=mail.google.com'>

这是基于pyOpenSSL的,因为我需要获取无效证书,并且Python内置的ssl模块总是尝试验证收到的证书。

1
即使您连接到HTTPS代理服务器,它是否适用于您?在您的示例中,您正在连接到普通代理服务器,这对我也起作用。当我需要双重包装套接字时,它就会失败。 - Eli Courtwright
很好的回答,但为什么不能使用ssl.wrap_socket呢? - Dima Tisnek
1
这种方法在HTTPS-over-HTTPS的情况下效果不佳,会导致相同的错误。 - 02strich
1
我收到了 Error: [('SSL routines', 'SSL23_GET_SERVER_HELLO', 'unknown protocol')] 的错误,而在 ssl depth-1 输出中没有二进制垃圾。我怀疑 OpenSSL 重复使用底层套接字/fd 只包装一次数据,而不是两次在 SSL 中包装数据。 - Dima Tisnek
1
你没有使用HTTPS代理,我认为这只是一个HTTP代理中的HTTPS示例。 - Reorx

5
根据OpenSSL和GnuTLS库的API,将一个SSLSocket堆叠到另一个SSLSocket上实际上并不容易,因为它们提供了特殊的读/写函数来实现加密,但在包装预先存在的SSLSocket时无法使用这些函数。
因此,错误是由内部SSLSocket直接从系统套接字读取而不是从外部SSLSocket读取引起的。这会导致发送不属于外部SSL会话的数据,这样做会结束得很糟糕,并且肯定不会返回有效的ServerHello。
综上所述,我认为没有简单的方法来实现您(实际上也是我)想要完成的任务。

听起来是一个合理的解释NPI。你可能知道其他的选择吗? - Dima Tisnek
很遗憾,如果你有任何想法,我全耳倾听。 - 02strich
我通过socket.socketpair成功地循环传输数据,哈哈。 - Dima Tisnek
@qarma,所以你从SSLSocket中读取它,将其写入socketpair,然后再从socketpair的另一端的第二个SSLSocket中读取?! - 02strich
没错,基本上就是这样。同时我发现 twisted 包似乎通过其 SSL/TLS 模块中的自定义 BIO 支持 SSL-in-SSL,但这需要很多依赖项。 - Dima Tisnek

2

最后我在@kravietz和@02strich的回答基础上有所收获。

这里是代码:

import threading
import select
import socket
import ssl

server = 'mail.google.com'
port = 443
PROXY = ("localhost", 4433)
CONNECT = "CONNECT %s:%s HTTP/1.0\r\nConnection: close\r\n\r\n" % (server, port)


class ForwardedSocket(threading.Thread):
    def __init__(self, s, **kwargs):
        threading.Thread.__init__(self)
        self.dest = s
        self.oursraw, self.theirsraw = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
        self.theirs = socket.socket(_sock=self.theirsraw)
        self.start()
        self.ours = ssl.wrap_socket(socket.socket(_sock=self.oursraw), **kwargs)

    def run(self):
        rl, wl, xl = select.select([self.dest, self.theirs], [], [], 1)
        print rl, wl, xl
        # FIXME write may block
        if self.theirs in rl:
            self.dest.send(self.theirs.recv(4096))
        if self.dest in rl:
            self.theirs.send(self.dest.recv(4096))

    def recv(self, *args):
        return self.ours.recv(*args)

    def send(self, *args):
        return self.outs.recv(*args)


def test():
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect(PROXY)
    s = ssl.wrap_socket(s, ciphers="ALL:aNULL:eNULL")
    s.send(CONNECT)
    resp = s.read(4096)
    print (resp, )

    fs = ForwardedSocket(s, ciphers="ALL:aNULL:eNULL")
    fs.send("foobar")

不用担心自定义的cihpers=,那只是因为我不想处理证书。

还有深度1的ssl输出,显示CONNECT,我的响应是ssagd和深度2的ssl协商和二进制垃圾:

[dima@bmg ~]$ openssl s_server  -nocert -cipher "ALL:aNULL:eNULL"
Using default temp DH parameters
Using default temp ECDH parameters
ACCEPT
-----BEGIN SSL SESSION PARAMETERS-----
MHUCAQECAgMDBALAGQQgmn6XfJt8ru+edj6BXljltJf43Sz6AmacYM/dSmrhgl4E
MOztEauhPoixCwS84DL29MD/OxuxuvG5tnkN59ikoqtfrnCKsk8Y9JtUU9zuaDFV
ZaEGAgRSnJ81ogQCAgEspAYEBAEAAAA=
-----END SSL SESSION PARAMETERS-----
Shared ciphers: [snipped]
CIPHER is AECDH-AES256-SHA
Secure Renegotiation IS supported
CONNECT mail.google.com:443 HTTP/1.0
Connection: close

sagq
�u\�0�,�(�$��
�"�!��kj98���� �m:��2�.�*�&���=5�����
��/�+�'�#��     ����g@32��ED���l4�F�1�-�)�%���</�A������
                                                        ��      ������
                                                                      �;��A��q�J&O��y�l

1

从你所做的事情来看,似乎没有任何问题;在现有的SSLSocket上调用wrap_socket()是完全可能的。

'未知协议'错误可能会发生(除其他原因外),如果在调用wrap_socket()时,套接字上有额外的数据等待读取,例如额外的\r\n或HTTP错误(由于服务器端缺少证书等原因)。您确定在那个时间点已经读取了所有可用的内容吗?

如果您可以强制第一个SSL通道使用“普通”的RSA密码(即非Diffie-Hellman),那么您可以使用Wireshark解密流以查看发生了什么。


我非常确定套接字上没有可用内容,因为如果我调用 sock.recv(1) ,它会无限期地阻塞。但是,感谢您确认我可以双重包装套接字。虽然我无法更改服务器的 SSL 设置,但感谢Wireshark的建议 - 如果您有其他想法,请告诉我。 - Eli Courtwright
1
按照SimonJ的建议去做。1)SSL套接字与常规套接字的工作方式不同。即使有原始接收到的SSL数据,除非接收到完整且有效的SSL记录,否则不会返回任何数据。2)您无需更改服务器上的任何内容即可强制使用RSA,只需修改客户端密码套件以排除使用diffie-hellman的任何密码套件。当然,您还需要获取服务器的私钥进行解密,因此如果无法获取该私钥,则只能看到密码。Wireshark为您提供了基本事实:请尝试一下。 - President James K. Polk
1
客户端可以直接使用SSL连接服务器吗?也许由于您的网络拓扑结构不允许此操作,但最好确认一下是否存在某些协议级别的问题(如SSL版本或密码套件不兼容),以防止终端无法通信。 - SimonJ

0

在 @kravietz 的回答基础上进行改进。这是一个适用于 Python3 且通过 Squid 代理工作的版本:

from OpenSSL import SSL
import socket

def verify_cb(conn, cert, errun, depth, ok):
        return True

server = 'mail.google.com'
port = 443
PROXY_ADDR = ("<proxy_server>", 3128)
CONNECT = "CONNECT %s:%s HTTP/1.0\r\nConnection: close\r\n\r\n" % (server, port)

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(PROXY_ADDR)
s.send(str.encode(CONNECT))
s.recv(4096)

ctx = SSL.Context(SSL.SSLv23_METHOD)
ctx.set_verify(SSL.VERIFY_PEER, verify_cb)
ss = SSL.Connection(ctx, s)

ss.set_connect_state()
ss.do_handshake()
cert = ss.get_peer_certificate()
print(cert.get_subject())
ss.shutdown()
ss.close()

这在Python 2中也有效。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接