使用TLS加密TCP流量

Published On September 13, 2017

category server | tags openssl https epoll echo


这段时间涉及到修改openssl ssl协议握手的代码,对ssl有了更深的认识。本文先粗略的介绍SSL协议的原理,包括握手流程和涉及到的算法;然后通过一个具体的例子——一个使用ssl协议加密的基于epoll的echo server,讲解ssl库的简单使用。

SSL(Secure Sockets Layer)是TLS(Transport Layer Security)的前身,TLS1.0是SSL3.0的升级,现在用的主要是TLS1.2。经常用SSL代指TLS,下面对TLS和SSL不作区分。

TLS简单介绍

网络上的流量很容易被窃听,如果要传输敏感数据比如登陆密码,我们必须要加密,否则这些数据将被中间人一览无余。据说微信使用自定义的协议(带加密)和服务端进行通信,从而防止被抓包。其实,最简单可靠的方式就是使用TLS协议加密,比如HTTPS。HTTPS并不是一种协议,它是使用TLS协议对HTTP的内容加密,TLS工作在TCP和HTTP协议之间。TLS对上层协议的数据使用对称密钥算法(比如AES256)进行加/解密,中间人只能看到密文,目前TLS协议已经被google,facebook等大型网站广泛使用。

事实上,SSL的作用不仅是对通信的数据进行加密,还必须要能验证服务端和客户端的身份。比如使用https能通过证书验证服务端的身份,如果不能确认服务端的身份,通过冒充服务端的方式进行中间人攻击将易如反掌。

握手流程

SSL通信的第一个阶段是握手,握手发生在TCP建立连接之后,HTTP传输数据之前。双向握手的流程如图所示,对于https而言,没有Client Certificate这个步骤: handshake.png

握手的主要目的是验证对方的身份并协商加密用的密钥,即Key exchange and certificate verification。

密钥交换

对数据加密通常使用对称加密算法,使用密钥对明文数据加密得到密文,然后使用同一个密钥解密还原成明文。因为加密和解密使用同一个密钥,所以叫做对称加密。 在加密数据之前,客户端和服务端需要协商加密所使用的密钥,该密钥不能以明文的方式直接通过网络传输(防止被中间人拿到),因此就需要密钥交换算法,常用的有RSA和DH。

RSA是一种公钥算法(非对称加密算法),有两个密钥,一个公钥,一个私钥,公钥是任何人可见的,使用公钥加密的数据只有用私钥才能加密,反之亦然。客户端使用服务端的公钥对 对称加密的密钥(master key)进行加密后再传输给服务端,服务端使用私钥解密,之后客户端和服务端就可以通过这个master key进行加密传输了。实际上,客户端并没有发送master key,而是一个pre-master key,双方根据client random、server random和pre-master key计算出同一个master key,目的是保证生成的密钥足够随机,但这个不是我们讨论的关键。

DH就更神奇了,它根本不用传输这个pre-master key,客户端有一对公钥和私钥,服务端也有一对公钥和私钥,彼此交换公钥后就可以根据这些参数计算出同一个master key。有关DH的原理比较复杂,笔者也不太懂。

certificate verification

如何验证对方的身份,比如访问https的网址,如何验证tcp连接的另一端确实是该网站的服务器呢?握手过程中,服务端会发送它的证书,证书中包含了它的公钥、域名等重要信息。使用以下命令查看一个证书包含的信息:

openssl x509 -text -noout -in my.crt
需要满足三个条件才能确定对方的身份:

  1. 该证书包含的域名或ip是否与当前访问的域名或ip匹配
  2. 该证书是受信任的
    • 该证书被手动加入到了受信任的证书列表
    • 该证书是由一个受信任的CA颁发的(即被CA的私钥签过名的,比如使用RSA进行签名),这些受信任的CA证书一般是系统自带的,可以使用CA的公钥对签名解密进行确认
  3. 对方是该证书的持有者:通常使用RSA进行验证。RSA不仅可以用来交换密码,还可以作为一种签名算法(Signature Algorithm)。服务端使用RSA的私钥进行加密,同时发送明文和明文,客户端使用公钥解密后如果与明文一致说明服务端确实是证书持有者(除非私钥被泄露);除此之外,常用的ECDSA是一种更高级的替代算法算法。

以上三点缺一不可,否则,中间人将有可乘之机。

cipher suite

ssl协议的整个通信过程包含了很多密码学的算法,这些算法的组合称为cipher suite。openssl支持的cipher suite可以使用命令openssl ciphers -V | column -t查看 cipher_suites.png

比如ECDHE-RSA-AES256-GCM-SHA384表示:

  • Kx: 使用ECDHE作为密钥交换算法(key exchange),ECDHE算法是DH算法的变体;
  • Au: 使用RSA作为证书认证算法(Authentication)
  • Enc: 使用AES256-GCM对数据进行加密(Encryption)传输;
  • Mac: Message authentication code (MAC)的作用是进行数据一致性校验,AEAD(Authenticated Encryption with Associated Data)不是一种具体的加密算法,而是一类提供数据一致性保护的加密算法的统称,AES256的GCM模式就是这样一种具体的算法;
  • 使用SHA384作为消息摘要/散列函数,具体什么地方用到了?

异步TLS通信示例

事实上,TLS可以用来加密任何TCP流量,下面是一个同时使用epoll和TLS实现的非阻塞echo server。将echo协议替换成http就成了支持https的http服务器,比如nginx,替换成socks5协议就是一个shadow-socks服务。

源码

echo_epoll_ssl.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/epoll.h>
#include <openssl/ssl.h>
#include <openssl/err.h>

#define MAXEVENTS 64

typedef struct userdata_s {
    int fd;
    SSL * ssl;
} userdata_t;

void error(char *msg)
{
    perror(msg);
    exit(1);
}

static void make_socket_non_blocking(int sfd)
{
    int flags, s;

    flags = fcntl(sfd, F_GETFL, 0);
    if (flags == -1)
    {
        error("fcntl get");
    }

    flags |= O_NONBLOCK;
    s = fcntl(sfd, F_SETFL, flags);
    if (s == -1)
    {
        error("fcntl set");
    }
}

static int create_and_bind(char *port)
{
    struct addrinfo hints;
    struct addrinfo *result, *rp;
    int s, sfd;

    memset(&hints, 0, sizeof(struct addrinfo));
    hints.ai_family = AF_UNSPEC;     /* Return IPv4 and IPv6 choices */
    hints.ai_socktype = SOCK_STREAM; /* We want a TCP socket */
    hints.ai_flags = AI_PASSIVE;     /* All interfaces */

    s = getaddrinfo(NULL, port, &hints, &result);
    if (s != 0)
    {
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(s));
        return -1;
    }

    for (rp = result; rp != NULL; rp = rp->ai_next)
    {
        sfd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
        if (sfd == -1)
            continue;

        s = bind(sfd, rp->ai_addr, rp->ai_addrlen);
        if (s == 0)
        {
            /* We managed to bind successfully! */
            break;
        }

        close(sfd);
    }

    if (rp == NULL)
    {
        fprintf(stderr, "Could not bind\n");
        return -1;
    }

    freeaddrinfo(result);

    return sfd;
}

SSL_CTX* init_ssl_ctx()
{
    SSL_CTX *ctx;

    SSL_load_error_strings();   
    OpenSSL_add_ssl_algorithms();

    /* create ctx */
    const SSL_METHOD *method;

    method = SSLv23_server_method();

    ctx = SSL_CTX_new(method);
    if (!ctx) {
        /* prints the error strings for all errors that OpenSSL has recorded to 
           stderr, thus emptying the error queue*/
        ERR_print_errors_fp(stderr);
        error("Unable to create SSL context");
    }

    SSL_CTX_set_ecdh_auto(ctx, 1);

    /* Set the key and cert */
    if (SSL_CTX_use_certificate_file(ctx, "cert.pem", SSL_FILETYPE_PEM) <= 0) {
        ERR_print_errors_fp(stderr);
        error("Unable to set certificate");
    }
    if (SSL_CTX_use_PrivateKey_file(ctx, "cert.key", SSL_FILETYPE_PEM) <= 0 ) {
        ERR_print_errors_fp(stderr);
        error("Unable to set private key");
    }
    return ctx;
}

int ssl_handshake(SSL *ssl)
{
    int n, sslerr;

    n = SSL_do_handshake(ssl);
    if (n == 1)
    {
        printf("ssl connection established\n");
        return 1;
    }
    else
    {
        sslerr = SSL_get_error(ssl, n);
        switch (sslerr)
        {
            case SSL_ERROR_WANT_READ:
            case SSL_ERROR_WANT_WRITE:
                return EAGAIN;
            default:
                ERR_print_errors_fp(stderr);
                error("ssl handshake");
        }
    }
}

int main(int argc, char *argv[])
{
    int sfd, s;
    int efd;

    /*
    typedef union epoll_data {
        void    *ptr;
        int      fd;
        uint32_t u32;
        uint64_t u64;
    } epoll_data_t;

    struct epoll_event {
        uint32_t     events;
        epoll_data_t data;
    };
    */
    /* Since epoll_data is a union, only one field can be used. ptr is always 
       used to pointer a user data structure which then contain file descriptor.
       Do not forget to free user data structure you created. */
    struct epoll_event event;
    struct epoll_event *events;

    if (argc != 2)
    {
        fprintf(stderr, "Usage: %s [port]\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    sfd = create_and_bind(argv[1]);
    if (sfd == -1)
        abort();

    make_socket_non_blocking(sfd);

    s = listen(sfd, SOMAXCONN);
    if (s == -1)
    {
        error("listen");
    }

    efd = epoll_create1(0);
    if (efd == -1)
    {
        error("epoll_create");
    }

    userdata_t *data = (userdata_t*)malloc(sizeof(userdata_t));
    data->fd = sfd;
    event.data.ptr = data;
    event.events = EPOLLIN | EPOLLET;
    s = epoll_ctl(efd, EPOLL_CTL_ADD, sfd, &event);
    if (s == -1)
    {
        error("epoll_ctl");
    }

    /* Buffer where events are returned */
    events = calloc(MAXEVENTS, sizeof event);

    SSL_CTX *ctx = init_ssl_ctx();

    /* The event loop */
    while (1)
    {
        int n, i, fd;

        n = epoll_wait(efd, events, MAXEVENTS, -1);
        for (i = 0; i < n; i++)
        {
            fd = ((userdata_t *)events[i].data.ptr)->fd;
            if ((events[i].events & EPOLLERR) ||
                (events[i].events & EPOLLHUP) ||
                (!(events[i].events & EPOLLIN)))
            {
                if (events[i].events & EPOLLERR)
                    fprintf(stderr, "epoll error: EPOLLERR\n");
                if (events[i].events & EPOLLHUP)
                    fprintf(stderr, "epoll error: EPOLLHUP\n");
                close(fd);
                free(events[i].data.ptr);
                continue;
            }
            else if (sfd == fd)
            {
                /* We have a notification on the listening socket, which
                   means one or more incoming connections. */
                while (1)
                {
                    struct sockaddr in_addr;
                    socklen_t in_len;
                    int infd;
                    char hbuf[NI_MAXHOST], sbuf[NI_MAXSERV];

                    in_len = sizeof in_addr;
                    infd = accept(sfd, &in_addr, &in_len);
                    if (infd == -1)
                    {
                        if ((errno == EAGAIN) || (errno == EWOULDBLOCK))
                        {
                            /* We have processed all incoming
                               connections. */
                            break;
                        }
                        else
                        {
                            perror("accept");
                            break;
                        }
                    }

                    s = getnameinfo(&in_addr, in_len,
                                    hbuf, sizeof hbuf,
                                    sbuf, sizeof sbuf,
                                    NI_NUMERICHOST | NI_NUMERICSERV);
                    if (s == 0)
                    {
                        printf("Accepted connection on descriptor %d "
                                       "(host=%s, port=%s)\n", infd, hbuf, sbuf);
                    }

                    /* Make the incoming socket non-blocking and add it to the
                       list of fds to monitor. */
                    userdata_t *data = (userdata_t*)malloc(sizeof(userdata_t));
                    make_socket_non_blocking(infd);
                    data->fd = infd;

                    SSL *ssl = SSL_new(ctx);
                    SSL_set_accept_state(ssl);
                    SSL_set_fd(ssl, infd);
                    data->ssl = ssl;

                    event.data.ptr = data;
                    event.events = EPOLLIN | EPOLLET;
                    s = epoll_ctl(efd, EPOLL_CTL_ADD, infd, &event);
                    if (s == -1)
                    {
                        error("epoll_ctl");
                    }
                }
                continue;
            }
            else
            {
                /* start or continue ssl handshake */
                SSL *ssl = ((userdata_t*)events[i].data.ptr)->ssl;
                if ( !SSL_is_init_finished(ssl) )
                {
                    ssl_handshake(ssl);
                    continue;
                }

                int done = 0;
                int sslerr;
                ssize_t count;
                char buf[512];

                count = SSL_read(ssl, buf, sizeof buf); // read
                if (count > 0)
                {
                    count = SSL_write(ssl, buf, count); // write
                }

                if (count < 0)
                {
                    sslerr = SSL_get_error(ssl, count);
                    if (sslerr != SSL_ERROR_WANT_READ && sslerr != SSL_ERROR_WANT_WRITE) {
                        ERR_print_errors_fp(stderr);
                        done = 1;
                    }
                }
                else if (count == 0)
                {
                    /* The remote has closed the connection. Do I have to call SSL_shutdown? */
                    done = 1;
                }

                if (done)
                {
                    printf("Close connection on descriptor %d\n", fd);
                    SSL_free(ssl);
                    /* Closing the descriptor will make epoll remove it
                       from the set of descriptors which are monitored. */
                    close(fd);
                    free(events[i].data.ptr);
                }
            }
        }
    }

    // clean
    free(events);
    close(sfd);
    SSL_CTX_free(ctx);
    EVP_cleanup();

    return EXIT_SUCCESS;
}
编译
gcc -g -lssl -lcrypto echo_epoll_ssl.c -o echo_epoll
openssl实际上包含了两个动态链接库,ssl和crypto,ssl是与ssl协议相关的接口,ssl库会调用crypto,crypto库包含了一些通用的加密函数,比如AES256,SHA256等,可以单独使用。 link选项包含crypto是因为代码中用了ERR_print_errors_fp,它的实现位于crypto库中。

SSL的API

实现TLS协议的库是openssl,ssl.h头文件包含了与ssl相关的所有接口,如果需要某个功能,可以到该文件中找,本示例主要用到以下函数:

SSL_CTX *SSL_CTX_new(const SSL_METHOD *method); 创建SSL_CTX对象,作为创建SSL的上下文提供默认的设置。 method参数表示所用的SSL协议版本,通常使用const SSL_METHOD *SSLv23_server_method(void);的返回值作为参数,TLS会选择可用的最高版本。 与之对应的虚构函数是void SSL_CTX_free(SSL_CTX *);

SSL *SSL_new(SSL_CTX *ctx); 创建SSL结构体,该结构体包含一个TLS/SSL连接的相关数据。 与之对应的虚构函数是void SSL_free(SSL *ssl);

int SSL_do_handshake(SSL *ssl); 进行SSL握手,调用之前需要通过SSL_set_connect_stateSSL_set_accept_state设置ssl工作在客户端还是服务端模式。也可以直接使用SSL_connectSSL_accept替代SSL_do_handshake。 通过前面的介绍可以知道SSL的握手包含2个rtt,如果使用异步IO,那么需要多次调用SSL_do_handshake才能完成握手。通过它的返回值可以确定:

  • 1:handshake was successfully completed
  • 0:handshake was not successful
  • <1:
    • a fatal error occurred either at the protocol level or a connection failure occurred
    • need to continue the operation for non-blocking BIOs

对于返回值<1的情况,使用返回值作为第2个参数调用int SSL_get_error(const SSL *ssl, int ret);,从而区分是不是异常错误,如果SSL_get_error返回SSL_ERROR_WANT_READ就表示SSL握手等待客户端的输入,此时就需要等待客户端socket的可读事件发生后然后继续调用SSL_do_handshake。与之类似的是SSL_ERROR_WANT_WRITE

int SSL_read(SSL *ssl, void *buf, int num); 接收数据

int SSL_write(SSL *ssl, const void *buf, int num); 发送数据 两个函数与read和write系统调用的签名很类似,只不过是把socket描述符换成了SSL结构体。

使用openssl命令行生成自签名的证书

自签名证书(self-sign certificate)是指subject和issuer是同一个组织,

A certificate with a subject that matches its issuer, and a signature that can be verified by its own public key.

openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
需要输入的信息使用默认值即可,命令结束后会在当前目录下生成公钥cert.pem和私钥key.pem。

注意:这种方式创建的自签名证书,即使在系统中安装了这个证书,chrome依旧会提示不安全,因为缺少SAN(Server Alternative Name)。

使用openssl s_client测试

openssl提供了一个命令行版本的客户端方便测试ssl服务,比如使用以下命令就可以和taobao建立SSL连接,默认会打印出证书的内容(BASE64编码)、通信使用的cipher suite、master secret和session id:

openssl s_client -connect www.taobao.com:443

启动上面的echo server

./a.out 8000
在另一个shell窗口执行
openssl s_client -connect localhost:8000 -quiet
建立连接后输入hello,按下回车发送

该命令有很多选项,比如:

显示ssl协议的内容

openssl s_client -connect localhost:8000 -debug -msg

指定cipher suite

openssl s_client -connect localhost:8000 -cipher AES256-SHA256

使用wireshark抓包

使用wireshark能看到SSL握手的过程,通过配置master-key还能解密应用数据。

握手过程

如果服务端监听的不是443端口,wireshark默认不会将TCP的数据按照SSL协议解析。如果服务端监听8000端口,对于MacOS需要将Wireshark->Preference->Protocols->HTTP->SSL/TLS Ports由443改为443,8000。

如图所示是握手对应的TCP报文段: wireshark_handshake.png

1-3:TCP 3次握手
4:Client Hello
5:对上一个报文段的ACK
6:Server Hello, Certificate, Server Hello Done
7:ACK
8:Client Key Exchange, Change Cipher Spec, Encrypted Handshake Message(Server Finished)
9:Change Cipher Spec, Encrypted Handshake Message(Server Finished)
10: ACK

解密流量

这个功能似乎并不是每次都好使。 创建一个文件用来保存session id和master secret

vim ~/sslkey.log

在wireshark->preference->Protocols->ssl,设置(Pre)-Master-Secret log filename为刚刚创建的文件,并设置ssl的日志文件为ssl.log。 wireshark_decode_ssl.png

将openssl s_client打印出来的session id和master secret保存到sslkey.log中,格式如下:

RSA Session-ID:3D6C45EBFEF0969D4C0D35BD0D962EA2B1F96B8196DC3B540567646D339065C0 Master-Key:E4255513BB3217A56D5C4AE61842E7383912CF8B8480022C5F661E12295DF669E37B0B6C0E29E36D1FFD0119695EFB4D
使用wireshark捕获8000端口的流量,Application-Data报文即可ssl.log日志显示已经成功解析了ssl加密数据,但wireshark并不会显示出明文来,似乎是因为ssl上层的协议不是http。 wireshark_capture1.png wireshark_capture2.png

参考


qq email facebook github
© 2018 - Xurui Yan. All rights reserved
Built using pelican