Linux網絡編程 - 檢查數據的有效性

在前面,我們仔細分析了引起故障的原因,並且已經知道爲了應對可能出現的各種故障,必須在程序中做好防禦工作。

對端的異常情況

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;
}

綜上,我們一定要時刻提醒自己做好應對各種複雜情況的準備,這裏的異常情況包括緩衝區溢出、指針錯誤、連接超時檢測等。

 

溫故而知新 !

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章