最近在公司修复了几个Bug

  最近在公司修复了几个bug,主要涉及到openSSL和libcurl两个开发组件的使用方式。这些库虽然历史悠久而且使用广泛,但是他们的开发文档却实不咋滴,libcurl由于本身比较简单而且官方也附带了一些使用样例,因此相对来说还好说,但是openSSL就真的比较扯蛋了,本来安全相关的加密解密就比较深奥,其官方手册也就一个个零散的函数说明让使用者无从下手,以至于很多新手都是搜索代码然后拷贝拼凑出来的,对整个系统来说是个极大的安全性和稳定性隐患。

一、Libcurl

  Libcurl官方号称其本身是线程安全的,所以用户可以以非阻塞方式或者在多线程中以阻塞方式调用多个实例,但是libcurl的共享数据是非线程安全的,共享数据包括DNS,Cookie以及用户自定义的共享数据。Libcurl提供了设置加锁和解锁回调接口,使得在访问和修改这类数据的时候自动实现保护,简化了使用者的操作。值得一提的是,针对诸如DNS这类共享情况,采用读写锁相比于互斥锁可能会获得更高的性能,而共享的用户自定义数据,如果没有修改操作则可以不使用锁保护。

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
pthread_rwlock_t rwlock;
CURLSH* share_handle = NULL;

bool curlInit() {
curl_global_init(CURL_GLOBAL_ALL);
pthread_rwlock_init(&rwlock);
share_handle = curl_share_init();
curl_share_setopt(_share_handle, CURLSHOPT_SHARE, CURL_LOCK_DATA_DNS);
curl_share_setopt(_share_handle, CURLSHOPT_LOCKFUNC, curlLock);
curl_share_setopt(_share_handle, CURLSHOPT_UNLOCKFUNC, curlUnlock);
}

static void curlLock(CURL \*h, curl_lock_data data, curl_lock_access access, void \*userptr){
if (data == CURL_LOCK_DATA_DNS){
if ( access == CURL_LOCK_ACCESS_SHARED )
pthread_rwlock_rdlock(&rwlock);
else if( access == CURL_LOCK_ACCESS_SINGLE )
pthread_rwlock_wrlock(&rwlock);
}
}

static void curlUnlock(CURL \*handle, curl_lock_data data, void \*userptr){
if (data == CURL_LOCK_DATA_DNS)
pthread_rwlock_unlock(&rwlock);
}

  这样,在后续各个线程创建CURL对象后,就可以调用下面的方式进行数据的共享了:

1
curl_easy_setopt( curl, CURLOPT_SHARE, share_handle );

  此外还需要注意的一点是,如果你的客户端使用libcurl进行HTTPS协议通信,但是发现内存不断泄露的话,注意从CURLOPT_SSL_VERIFYPEER这个参数进行排查,或许会有所收获。

二、openSSL

  就像上个世纪很多的C库一样,openSSL历史悠久默认也是非线程安全的。如果要在多线程下使用openSSL,可以参照libcurl对多线程SSL使用的官方指导方式来进行设定,其通常是服务启动的时候静态创建所需数量的结构锁,并且指定id_callback和locking_callback两个回调函数,用于返回线程标识和设定线程保护的锁调用。
  使用openSSL的时候,在程序进行任何SSL操作之前,需调用SSL_library_init()且只需要全局初始化一次,然后可以创建一个SSL_CTX对象并做一些参数设定,这个SSL_CTX可以只创建一个全局变量,并供之后所有线程的SSL操作共享使用,后面所有SSL_new创建的SSL都可以根据这个SSL_CTX来创建(除非有其他不同的设置需求),不过在其有使用者的时候不应该再对其做任何修改。一个合理完备的openSSL初始化可以是下面锁描述的样子,记得要在任何openSSL操作之前(最好是启动其他线程之前)调用该环境设置函数:

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
static pthread_mutex_t* mutex_buf = NULL;
SSL_CTX* global_ssl_ctx = NULL;

static void pthreads_locking_callback(int mode, int type, char *file, int line) {
if (mode & CRYPTO_LOCK)
pthread_mutex_lock(&(mutex_buf[type]));
else
pthread_mutex_unlock(&(mutex_buf[type]));
}

static unsigned long pthreads_thread_id(void) {
return (unsigned long)pthread_self();
}

static SSL_CTX* ssl_setup_client_ctx() {
if (global_ssl_ctx)
return global_ssl_ctx;

global_ssl_ctx = SSL_CTX_new(SSLv23_method()); // 兼容SSLv3 & TLS_v1
return global_ssl_ctx;
}

bool Ssl_thread_setup() {
mutex_buf = (pthread_mutex_t *)OPENSSL_malloc(CRYPTO_num_locks() * sizeof(pthread_mutex_t));
for (int i=0; i<CRYPTO_num_locks(); ++i) {
pthread_mutex_init(&(mutex_buf[i]), NULL);
}

CRYPTO_set_id_callback((unsigned long (*)())pthreads_thread_id);
CRYPTO_set_locking_callback((void (*)(int, int, const char*, int))pthreads_locking_callback);

SSL_library_init();
SSL_load_error_strings(); // debug error info
ssl_setup_client_ctx();

// 屏蔽不安全的SSLv2协议,但是兼容SSLv3协议
SSL_CTX_set_options(global_ssl_ctx, SSL_OP_ALL|SSL_OP_NO_SSLv2);
SSL_CTX_set_options(global_ssl_ctx, SSL_MODE_AUTO_RETRY); // auto retry
return true;
}

void Ssl_thread_clean() {
if (!mutex_buf) return;

if (global_ssl_ctx)
SSL_CTX_free(global_ssl_ctx);

CRYPTO_set_id_callback(NULL);
CRYPTO_set_locking_callback(NULL);
for (int i=0; i<CRYPTO_num_locks(); ++i)
pthread_mutex_destroy(&(mutex_buf[i]));

OPENSSL_free(mutex_buf);
mutex_buf = NULL;
return;
}

  至于openSSL的IO操作,为了方便使用者openSSL提供了简单的IO操作接口,比如最常见的SSL_connect、SSL_write以及SSL_read等,不过使用的时候这些IO操作和普通socket 层次上的IO操作不同,后者的IO出错通常都会是确定性的错误,但是前者的一个IO调用实际封装了底层会涉及到协议相关的协商、重连接等很多操作,想说的是该调用暂时性的错误返回往往不是实际的错误,而需要使用者多次尝试后或许就能成功,所以openSSL的IO操作通常都放在循环体中执行多次尝试。
  SSL_read()和SSL_write()函数返回值>0表示成功,其结果是实际传输的字节数目,<=0则表示失败,此时需要使用SSL_get_error获得更详细的错误信息,因为错误信息是存放在线程相关的队列中的,所以实际IO操作和SSL_get_error查询错误信息的函数必须是同一个线程中执行,当然对于同步调用这通常都不是问题。常见的错误有:SSL_ERROR_ZERO_RETURN表明SSL会话已经关闭了,但是底层的连接可能还是打开的,具体应用程序根据需要处理这种情况;SSL_ERROR_WANT_READ|WRITE是最常见的,表明底层无法满足上层的IO请求,这种情况下上层IO请求应当被重新发起再次尝试。
  SSL传输也有阻塞模式和非阻塞模式之分别,和往常一样也是阻塞形式的调用比较简答,目前只讨论这种情况。如果SSL基于的底层socket是阻塞模式,那么该SSL连接也是阻塞模式的。一个完整的SSL连接建立的方式如下:

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
SSL* do_connect() {
struct sockaddr_in remote_addr;
memset(&remote_addr,0,sizeof(remote_addr));
remote_addr.sin_family = AF_INET;
remote_addr.sin_addr.s_addr = inet_addr("58.246.120.137");
remote_addr.sin_port = htons(443);

int fd = socket(AF_INET, SOCK_STREAM, 0);
connect(fd, (struct sockaddr *)&remote_addr,sizeof(struct sockaddr));
if (ret < 0)
return NULL;

SSL* ssl = SSL_new(global_ssl_ctx);
SSL_set_fd(ssl, fd);
if (SSL_connect(ssl) <= 0){
printf("SSL_connect error:%s",
ERR_error_string(SSL_get_error(ssl, error), NULL));
return NULL;
}

// X509* cert = NULL;
// cert = SSL_get_peer_certificate(ssl);

return ssl;
}

  然后,对于IO操作失败重试的话,一个通用的处理框架是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
while(true) {
int code = SSL_read(ssl, buf + offset, size - offset);
if (code <= 0) {
int error = SSL_get_error(ssl, code);
if (error == SSL_ERROR_ZERO_RETURN) {
/* react to the ssl connection being lost */
break;
} else if (error == SSL_ERROR_WANT_READ || error == SSL_ERROR_WANT_WRITE) {
::usleep(500); // fear 100% cpu
continue; // Just retry
} else {
/* default, treated as error */
break;
}
}
offset += code;
}

  其实,在openSSL中还可以通过设置SSL_CTX的SSL_MODE_AUTO_RETRY选项来设置openSSL的工作模式,通过将阻塞SSL设置在该模式下,openSSL会自动进行重新尝试直到上层的IO请求完成,或者发生某些错误的情况下才会返回,减轻了调用者需要手动进行部分状态检查和重试的工作量了!

1
2
3
// The flag SSL_MODE_AUTO_RETRY will cause read/write operations
// to only return after the handshake and successful completion
SSL_CTX_set_options(global_ssl_ctx, SSL_MODE_AUTO_RETRY);

  SSL_connect建立连接的时候,需要检查连接是否建立成功了,否则后续的读、写操作都会失败。对于SSL_connect失败,可以进行重连尝试,此处就不赘述了。

三、摘要加签和验签

  对于关键传输环境,都需要对传输数据进行签名和验签,由于原文件是变长的,所以通常采用单向摘要算法获得一个固定长度的摘要,然后对摘要使用秘钥签名。为了方便网络传输,通常还会对摘要进行Base64编码操作。
  如果客户端和服务端都使用同一种环境搭建,比如Java的Security库、C/C++的openSSL,就比较的好办,因为加签和验签几乎都是逆向操作,而且在手册中对应的接口函数也是相邻放置的,而如果是openSSL和Java进行交叉签名和验签,有些问题就会被暴露出来。
  前段时间就遇到一个坑,合作方是Java实现的服务端,我们是C++ openSSL实现的客户端,约定好Sha256withRsa的方式加签,可是互相验签都通不过。透过Google和Stackoverflow摸索了好久,才知道即使算法的名字一样,不同的Padding方式也会招致不同的计算结果。使用方式有下面两种:
  (1) 如果Java端直接使用Signature.getInstance(“SHA256WithRSA”);获取算法实例,那么后面的计算是将摘要获取和RSA签名合并操作了,此时可以对应使用openSSL的RSA_sign/RSA_verify来进行对应的操作,openSSL默认的padding算法此时是兼容的;
  (2) 如果Java端是使用MessageDigest.getInstance(“SHA-256”);手动计算摘要,然后再使用KeyFactory.getInstance(“RSA”);来进行签名,此时对应的openSSL需要手动使用诸如RSA_padding_add_PKCS1_type_1这类函数将摘要补齐到RSA_size秘钥的长度,然后使用RSA_private_encrypt/RSA_public_decrypt进行加签和验签操作才能通过。
  两种方式的Java代码贴在这里了,感兴趣的可以了解一下!

  好了,继续填坑去了。

本文完!

参考