openssl客戶端程式設計:一個不起眼的函式導致的SSL會話失敗問題

一隻會鏟史的貓發表於2022-06-27

我們目前大部分使用的openssl庫還是基於TLS1.2協議的1.0.2版本系列,如果要支援更高的TLS1.3協議,就必須使用openssl的1.1.1版本或3.0版本。升級openssl庫有可能會導致SSL會話失敗,我在升級 wincurl 時,意外的收穫了一個函式。這個函式非常的不起眼,但具有的現實意義卻很大。
大部分情況下如果你不呼叫該函式,並不影響SSL會話和通訊,但有時會被某些伺服器拒絕。一旦被拒絕,查詢具體的原因將變得非常痛苦。這個函式的意義好比HTTP協議中HOST欄位,它和NGINX反向代理的Server name有異曲同工之妙。瞭解這個函式的意義,可能會讓你今後在配置nginx反向代理時少走一些彎路。
這個函式是 SSL_set_tlsext_host_name,在介紹這個函式之前,我們先快速看看TLS協議和openssl的發展。

 
雖然TLS1.3的標準自2018年就已釋出,但目前國內幾乎所有網站並沒有增加對TLS 1.3的支援,大部分主流網站使用的還是基於TLS1.2的版本。目前國內使用TLS1.3的好像只有知乎,其它諸如:新浪、網易、搜狐、騰訊、華為、京東、百度....都還是採用TLS1.2的標準。
 
網站是否支援TLS1.3其實並不重要,重要的是瀏覽器是否支援TLS1.3協議。好在目前市場上所有主流瀏覽器都已升級到了TLS1.3。因此對於使用者而言,不管是支援TLS1.3的網站還是支援TLS1.2的網站,訪問起來都不是問題,或者說對終端使用者而言這種訪問是無感的。
 
為何TLS3.0已經出現5年了,各大網路平臺還是首選TLS1.2呢?估計還是出於對相容和穩定的考慮。如果貿然升級到1.3導致大量客戶端無法訪問從而丟失使用者,是任何平臺所無法承受的。此外,支援TLS1.3的openssl版本也在不斷完善中,在其未達到穩定前,最好還是使用穩定的1.2版本。這大概就是目前幾乎所有平臺類網站還在使用TLS1.2的原因。
 
此外,即使有的網站採用TLS1.3協議,也會繼續提供對1.2的訪問支援(向下相容並保留所有TLS1.2的加密套件),除非該網站的運維人員強行使用TLS1.3的加密套件。但如果這樣做的話就會導致大量仍然使用1.2標準的客戶端程式無法繼續訪問該網站,這也意味著該運維人員離下崗再就業不遠了。
 
這也是為什麼我們即使不升級老版本的openssl庫,也依然可以訪問TLS1.3網站的原因。
 
上面說的TLS1.2,1.3指的是TLS的協議,實現上述協議的是openssl庫。 其中支援TLS1.3的openssl庫目前有兩個版本系列:一個是1.1.1系列,一個是3.0系列。其中1.1.1更像是一個過渡版本,openssl團隊承諾會支撐該版本到2023年9月11日,也就是明年911事件22週年之際(確實是一個值得紀念的日子)將停止更新1.1.1版本。這就意味著大家還沒開始普及使用1.1.1,它就已經結束了。

這個其實也不重要,因為3.0才是最終openssl的終極版本,直接跳過了2.0,從而體現了openssl的跨越式發展。 3.0版本是openssl團隊主推版本,也是一個長期維護的版本,該版本會維護到2026年9月7日。

雖然主流web瀏覽器都已支援TLS1.3,但瀏覽器只是客戶端的一種,其它大部分客戶端使用的還是openssl 1.0.2系列版本,甚至是1.0.1系列版本,也就是我們經常看到的libeay32.dllssleay32.dll 庫,這些版本是不支援TLS1.3協議的。比如工具類的postman、curl,以及應用類QQ、Foxmail、微信以及各種下載客戶端程式,使用的可能還是老的openssl庫。openssl庫在1.1.1及以上版本已經將庫的匯出名稱改為libcryptolibssl,不過這只是字首名,如果是1.1.1系列,則字尾為-1.1.dll,如果是3.0系列,則字尾為-3.dll,我們通過openssl的庫名就能判斷出該應用程式是否支援TLS1.3。
 
升級到TLS1.3可以從openssl的官網https://openssl.org)下載1.1.1或3.0版本的原始碼進行編譯。編譯完畢後,我們只要替換openssl庫,並重新配置並編譯你的客戶端程式即可。
使用openssl庫編寫客戶端程式的一般流程如下:先建立套接字,並連線到伺服器,然後建立ssl,並繫結套接字,通過調動SSL_connect函式進行握手操作,成功後使用SSL_readSSL_write進行業務通訊。
程式碼通常如下:

	// 建立套接字,並連線到伺服器
	SOCKET s = socket(AF_INET, SOCK_STREAM, 0);
	struct hostent* hst = ::gethostbyname(pszServer);
	if(NULL == hst)
		return FALSE;
	unsigned long addr;
	struct sockaddr_in sockAddr;
	memcpy(&addr, hst->h_addr, hst->h_length);
	sockAddr.sin_family = AF_INET;
	sockAddr.sin_port = htons(nPort);
	sockAddr.sin_addr.S_un.S_addr = addr; 
	// 連線到伺服器
	if(SOCKET_ERROR != ::connect(s, (struct sockaddr *)&sockAddr, sizeof(struct sockaddr_in)))
		return FALSE;
	
	// Openssl庫初始化
	OpenSSL_add_ssl_algorithms();
	SSL_load_error_strings(); 
	SSLeay_add_ssl_algorithms();
	ERR_load_BIO_strings();
	ctx = SSL_CTX_new(SSLv23_client_method());
	
	// 建立ssl上下文,並繫結套接字
	SSL* ssl = SSL_new (m_ctx);
	SSL_set_fd(m_ssl, s);
	// 開始ssl握手
	int iRet = SSL_connect(m_ssl);
	if(1 != iRet)
		return FALSE;
		
	// 下面使用SSL_read或SSL_write進行通訊
	

上述程式碼中SSL_connect函式是最重要的,它內部實現了SSL的握手過程,openssl在內部採用狀態機的方式實現了整個握手,程式碼還是相當的晦澀。幾乎所有的SSL通訊失敗都是在握手階段產生的,比如加密套件不匹配,伺服器證書沒有可靠的簽名等。

openssl中有個s_client命令集合,該命令用於實現客戶端的通訊,它的實現在s_client.c檔案中,在這個龐大程式碼中有一個不起眼的函式呼叫,這個函式是SSL_set_tlsext_host_name。該函式的作用是在客戶端在傳送ClientHello訊息時,將所訪問的主機(伺服器)名稱寫入到server name的擴充套件欄位中。

   if (!noservername && (servername != NULL || dane_tlsa_domain == NULL)) {
        if (servername == NULL) {
            if(host == NULL || is_dNS_name(host)) 
                servername = (host == NULL) ? "localhost" : host;
        }
        if (servername != NULL && !SSL_set_tlsext_host_name(con, servername)) {
            BIO_printf(bio_err, "Unable to set TLS servername extension.\n");
            ERR_print_errors(bio_err);
            goto end;
        }
    }

呼叫該函式後會在ClientHello的擴充套件欄位中增加一個Server Name欄位。如下圖所示:

我們在客戶端程式設計時,通常不會呼叫該函式,因為會覺得該欄位可有可無,既然已經連線到Host伺服器上,為何還要將Host的名稱告訴給伺服器呢?如果我們不呼叫SSL_set_tlsext_host_name 函式,難道SSL會話會失敗麼?實際上即使不呼叫該函式,也是可以握手成功的,並不影響TLS的正常通訊。

但如果伺服器強制校驗該欄位時,就會導致握手失敗,這就好比HTTP協議中請求的HOST欄位一樣。那麼為什麼有的伺服器要強制校驗該欄位呢?如果一臺伺服器繫結不同的DSN名稱(也就是一個IP地址繫結多個域名),這個擴充套件欄位的意義就出來了,伺服器會根據不同的域名提供不同的證書。
 
舉個例子,假設BAT都破產了,他們窮到共用一臺伺服器的地步,也就是一個IP地址繫結了百度、阿里和騰訊三個公司域名,並且這三家公司都給域名申請了數字證書,此時擴充套件欄位的HOST的作用就體現出來了,openssl伺服器端會跟據不同的host名稱決定返回哪家公司的數字證書。這也是Ngnix反向代理的原理,根據不同域名進行不同訪問地址對映。

寫到這裡,本文也即將結束,也許我們呼叫了SSL_set_tlsext_host_name函式也無法知道它的最終目的是什麼,這也是我寫這篇文章的原因之一吧。

相關文章