在前面,我們仔細分析了引起故障的原因,並且已經知道爲了應對可能出現的各種故障,必須在程序中做好防禦工作。
對端的異常情況
int n = read(connfd, buf, 1024);
if(n < 0){
printf("error read\n");
}
else if(n == 0){
printf("client closed\n");
}
可以看到這一個程序中的第 5 行,當調用 read 函數返回 0 字節時,實際上就是操作系統內核返回 EOF 的一種反映。如果是服務器端同時處理多個客戶端連接,一般這裏會調用 shutdown 關閉連接的這一端。
上一講也講到了,不是每種情況都可以通過讀操作來感知異常,比如,服務器完全崩潰,或者網絡中斷的情況下,此時,如果是阻塞套接字,會一直阻塞在 read 等調用上,不會直接去感知套接字的異常。也有幾種辦法來解決這個問題:
第一個辦法是給套接字的 read 操作設置超時,如果超過了一段時間就認爲連接已經不存在。
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
setsockopt(connfd, SOL_SOCKET, SO_RCVTIMEO, (const char *) &tv, sizeof tv);
while (1) {
int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
if (nBytes == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
printf("read timeout\n");
onClientTimeout(connfd);
} else {
printf("error read message\n");
}
} else if (nBytes == 0) {
printf("client closed \n");
}
...
}
調用 setsockopt 函數,設置了套接字的讀操作超時,超時時間爲前面設置的 5 秒,當然在這裏這個時間值是“拍腦袋”設置的,比較科學的設置方法是通過一定的統計之後得到一個比較合理的值。關鍵之處在讀操作返回異常的行,根據出錯信息是EAGAIN或者EWOULDBLOCK,判斷出超時,轉而調用onClientTimeout函數來進行處理。
這個處理方式雖然比較簡單,卻很實用,很多 FTP 服務器端就是這麼設計的。連接這種 FTP 服務器之後,如果 FTP 的客戶端沒有續傳的功能,在碰到網絡故障或服務器崩潰時就會掛斷。
第二個辦法是,添加對連接是否正常的檢測。如果連接不正常,需要從當前 read 阻塞中返回並處理。
第三個辦法是,前面也提到過,那就是利用多路複用技術自帶的超時能力,來完成對套接字 I/O 的檢查,如果超過了預設的時間,就進入異常處理。
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
FD_ZERO(&allreads);
FD_SET(socket_fd, &allreads);
for (;;) {
readmask = allreads;
int rc = select(socket_fd + 1, &readmask, NULL, NULL, &tv);
if (rc < 0) {
printf("select failed\n");
}
if (rc == 0) {
printf("read timeout\n");
onClientTimeout(socket_fd);
}
...
}
這段代碼使用了 select 多路複用技術來對套接字進行 I/O 事件的輪詢,程序的 13 行是到達超時後的處理邏輯,調用onClientTimeout函數來進行超時後的處理。
緩衝區處理
一個設計良好的網絡程序,應該可以在隨機輸入的情況下表現穩定。隨着互聯網的發展,網絡安全也愈發重要,我們編寫的網絡程序能不能在黑客的刻意攻擊之下表現穩定,也是一個重要考量因素。很多黑客程序,會針對性地構建出一定格式的網絡協議包,導致網絡程序產生諸如緩衝區溢出、指針異常的後果,影響程序的服務能力,嚴重的甚至可以奪取服務器端的控制權,隨心所欲地進行破壞活動,比如著名的 SQL 注入,就是通過針對性地構造出 SQL 語句,完成對數據庫敏感信息的竊取。所以,在網絡程序的編寫過程中,我們需要時時刻刻提醒自己面對的是各種複雜異常的場景,甚至是別有用心的攻擊者,保持“防人之心不可無”的警惕。
那麼程序都有可能出現哪幾種漏洞呢?
第一個例子:
char Response[] = "COMMAND OK";
char buffer[128];
while (1) {
int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
if (nBytes == -1) {
printf("error read message\n");
continue;
} else if (nBytes == 0) {
printf("client closed \n");
continue;
}
buffer[nBytes] = '\0';
if (strcmp(buffer, "quit") == 0) {
printf("client quit\n");
send(socket, Response, sizeof(Response), 0);
}
printf("received %d bytes: %s\n", nBytes, buffer);
}
這段代碼從連接套接字中獲取字節流,並且判斷了出錯和 EOF 情況,如果對端發送來的字符是“quit”就回應“COMAAND OK”的字符流,乍看上去一切正常。
但這段代碼很有可能會產生下面的結果。
char buffer[128];
buffer[128] = '\0';
通過 recv 讀取的字符數爲 128 時,就會是文稿中的結果。因爲 buffer 的大小隻有 128 字節,最後的賦值環節,產生了緩衝區溢出的問題。
所謂緩衝區溢出,是指計算機程序中出現的一種內存違規操作。本質是計算機程序向緩衝區填充的數據,超出了原本緩衝區設置的大小限制,導致了數據覆蓋了內存棧空間的其他合法數據。這種覆蓋破壞了原來程序的完整性,使用過遊戲修改器的同學肯定知道,如果不小心修改錯遊戲數據的內存空間,很可能導致應用程序產生如“Access violation”的錯誤,導致應用程序崩潰。
我們可以對這個程序稍加修改,主要的想法是留下 buffer 裏的一個字節,以容納後面的'\0'。
int nBytes = recv(connfd, buffer, sizeof(buffer)-1, 0);
這個例子裏面,還昭示了一個有趣的現象。你會發現我們發送過去的字符串,調用的是sizeof,那也就意味着,Response 字符串中的'\0'是被髮送出去的,而我們在接收字符時,則假設沒有'\0'字符的存在。爲了統一,我們可以改成如下的方式,使用 strlen 的方式忽略最後一個'\0'字符。
send(socket, Response, strlen(Response), 0);
第二個例子:
前面提到了對變長報文解析的兩種手段,一個是使用特殊的邊界符號,例如 HTTP 使用的回車換行符;另一個是將報文信息的長度編碼進入消息。在實戰中,我們也需要對這部分報文長度保持警惕。
size_t read_message(int fd, char *buffer, size_t length) {
u_int32_t msg_length;
u_int32_t msg_type;
int rc;
rc = readn(fd, (char *) &msg_length, sizeof(u_int32_t));
if (rc != sizeof(u_int32_t))
return rc < 0 ? -1 : 0;
msg_length = ntohl(msg_length);
rc = readn(fd, (char *) &msg_type, sizeof(msg_type));
if (rc != sizeof(u_int32_t))
return rc < 0 ? -1 : 0;
if (msg_length > length) {
return -1;
}
/* Retrieve the record itself */
rc = readn(fd, buffer, msg_length);
if (rc != msg_length)
return rc < 0 ? -1 : 0;
return rc;
}
在進行報文解析時,第 15 行對實際的報文長度msg_length和應用程序分配的緩衝區大小進行了比較,如果報文長度過大,導致緩衝區容納不下,直接返回 -1 表示出錯。千萬不要小看這部分的判斷,試想如果沒有這個判斷,對方程序發送出來的消息體,可能構建出一個非常大的msg_length,而實際發送的報文本體長度卻沒有這麼大,這樣後面的讀取操作就不會成功,如果應用程序實際緩衝區大小比msg_length小,也產生了緩衝區溢出的問題。
struct {
u_int32_t message_length;
u_int32_t message_type;
char data[128];
} message;
int n = 65535;
message.message_length = htonl(n);
message.message_type = 1;
char buf[128] = "just for fun\0";
strncpy(message.data, buf, strlen(buf));
if (send(socket_fd, (char *) &message,
sizeof(message.message_length) + sizeof(message.message_type) + strlen(message.data), 0) < 0)
printf("send failure\n");
文稿裏就是這樣一段發送端“不小心”構造的一個程序,消息的長度“不小心”被設置爲 65535 長度,實際發送的報文數據爲“just for fun”。在去掉實際的報文長度msg_length和應用程序分配的緩衝區大小做比較之後,服務器端一直阻塞在 read 調用上,這是因爲服務器端誤認爲需要接收 65535 大小的字節。
第三個例子:
如果我們需要開發一個函數,這個函數假設報文的分界符是換行符(\n),一個簡單的想法是每次讀取一個字符,判斷這個字符是不是換行符。
文稿中給出了這樣的一個函數,這個函數的最大問題是工作效率太低,要知道每次調用 recv 函數都是一次系統調用,需要從用戶空間切換到內核空間,上下文切換的開銷對於高性能來說最好是能省則省。
size_t readline(int fd, char *buffer, size_t length)
{
char *buf_first = buffer;
char c;
while (length > 0 && recv(fd, &c, 1, 0) == 1) {
*buffer++ = c;
length--;
if (c == '\n') {
*buffer = '\0';
return buffer - buf_first;
}
}
return -1;
}
於是,就有了文稿中的第二個版本,這個函數一次性讀取最多 512 字節到臨時緩衝區,之後將臨時緩衝區的字符一個一個拷貝到應用程序最終的緩衝區中,這樣的做法明顯效率會高很多。
size_t readline(int fd, char *buffer, size_t length) {
char *buf_first = buffer;
static char *buffer_pointer;
int nleft = 0;
static char read_buffer[512];
char c;
while (length-- > 0) { //通過對 length 變量的判斷,試圖解決緩衝區長度溢出問題
if (nleft <= 0) { //判斷臨時緩衝區的字符有沒有被全部拷貝完,如果被全部拷貝完,就會再次嘗試讀取最多 512 字節
int nread = recv(fd, read_buffer, sizeof(read_buffer), 0);
if (nread < 0) {
if (errno == EINTR) {
length++;
continue;
}
return -1;
}
if (nread == 0)
return 0;
buffer_pointer = read_buffer; //在讀取字符成功之後,重置了臨時緩衝區讀指針、臨時緩衝區待讀的字符個數
nleft = nread;
}
/*拷貝臨時緩衝區字符,每次拷貝一個字符,並移動臨時緩衝區讀指針,對臨時緩衝區待讀的字符個
數進行減 1 操作*/
c = *buffer_pointer++;
*buffer++ = c;
nleft--;
/*判斷是否讀到換行符,如果讀到則將應用程序最終緩衝區截斷,返回最終讀取的字符個數*/
if (c == '\n') {
*buffer = '\0';
return buffer - buf_first;
}
}
return -1;
}
這個程序運行起來可能很久都沒有問題,但是,它還是有一個微小的瑕疵,這個瑕疵很可能會造成線上故障。爲了講請這個故障,我們假設這樣調用, 輸入的字符爲012345678\n。
//輸入字符爲: 012345678\n
char buf[10]
readline(fd, buf, 10)
當讀到最後一個\n 字符時,length 爲 1,如果讀到了換行符,就會增加一個字符串截止符,這顯然越過了應用程序緩衝區的大小。正確的程序我也附在了文稿中,這裏最關鍵的是需要先對 length 進行處理,再去判斷 length 的大小是否可以容納下字符。
size_t readline(int fd, char *buffer, size_t length) {
char *buf_first = buffer;
static char *buffer_pointer;
int nleft = 0;
static char read_buffer[512];
char c;
while (--length> 0) {
if (nleft <= 0) {
int nread = recv(fd, read_buffer, sizeof(read_buffer), 0);
if (nread < 0) {
if (errno == EINTR) {
length++;
continue;
}
return -1;
}
if (nread == 0)
return 0;
buffer_pointer = read_buffer;
nleft = nread;
}
c = *buffer_pointer++;
*buffer++ = c;
nleft--;
if (c == '\n') {
*buffer = '\0';
return buffer - buf_first;
}
}
return -1;
}
綜上,我們一定要時刻提醒自己做好應對各種複雜情況的準備,這裏的異常情況包括緩衝區溢出、指針錯誤、連接超時檢測等。
溫故而知新 !