<audio id="audio" title="18 | 防人之心不可无：检查数据的有效性" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6d/c4/6d1debeb6162a55ea86c0e68df7c6dc4.mp3"></audio>

你好，我是盛延敏，这里是网络编程实战第18讲，欢迎回来。

在前面一讲中，我们仔细分析了引起故障的原因，并且已经知道为了应对可能出现的各种故障，必须在程序中做好防御工作。

在这一讲里，我们继续前面的讨论，看一看为了增强程序的健壮性，我们还需要准备什么。

## 对端的异常状况

在前面的第11讲以及第17讲中，我们已经初步接触过一些防范对端异常的方法，比如，通过read等调用时，可以通过对EOF的判断，随时防范对方程序崩溃。

```
int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
if (nBytes == -1) {
    error(1, errno, &quot;error read message&quot;);
} else if (nBytes == 0) {
    error(1, 0, &quot;client closed \n&quot;);
}

```

你可以看到这一个程序中的第4行，当调用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 *) &amp;tv, sizeof tv);

while (1) {
    int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
    if (nBytes == -1) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            printf(&quot;read timeout\n&quot;);
            onClientTimeout(connfd);
        } else {
            error(1, errno, &quot;error read message&quot;);
        }
    } else if (nBytes == 0) {
        error(1, 0, &quot;client closed \n&quot;);
    }
    ...
}

```

这个代码片段在第4行调用setsockopt函数，设置了套接字的读操作超时，超时时间为在第1-3行设置的5秒，当然在这里这个时间值是“拍脑袋”设置的，比较科学的设置方法是通过一定的统计之后得到一个比较合理的值。关键之处在读操作返回异常的第9-11行，根据出错信息是`EAGAIN`或者`EWOULDBLOCK`，判断出超时，转而调用`onClientTimeout`函数来进行处理。

这个处理方式虽然比较简单，却很实用，很多FTP服务器端就是这么设计的。连接这种FTP服务器之后，如果FTP的客户端没有续传的功能，在碰到网络故障或服务器崩溃时就会挂断。

第二个办法是第12讲中提到的办法，添加对连接是否正常的检测。如果连接不正常，需要从当前read阻塞中返回并处理。

还有一个办法，前面第12讲也提到过，那就是利用多路复用技术自带的超时能力，来完成对套接字I/O的检查，如果超过了预设的时间，就进入异常处理。

```
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;

FD_ZERO(&amp;allreads);
FD_SET(socket_fd, &amp;allreads);
for (;;) {
    readmask = allreads;
    int rc = select(socket_fd + 1, &amp;readmask, NULL, NULL, &amp;tv);
    if (rc &lt; 0) {
      error(1, errno, &quot;select failed&quot;);
    }
    if (rc == 0) {
      printf(&quot;read timeout\n&quot;);
      onClientTimeout(socket_fd);
    }
 ...   
}

```

这段代码使用了select多路复用技术来对套接字进行I/O事件的轮询，程序的13行是到达超时后的处理逻辑，调用`onClientTimeout`函数来进行超时后的处理。

## 缓冲区处理

一个设计良好的网络程序，应该可以在随机输入的情况下表现稳定。不仅是这样，随着互联网的发展，网络安全也愈发重要，我们编写的网络程序能不能在黑客的刻意攻击之下表现稳定，也是一个重要考量因素。

很多黑客程序，会针对性地构建出一定格式的网络协议包，导致网络程序产生诸如缓冲区溢出、指针异常的后果，影响程序的服务能力，严重的甚至可以夺取服务器端的控制权，随心所欲地进行破坏活动，比如著名的SQL注入，就是通过针对性地构造出SQL语句，完成对数据库敏感信息的窃取。

所以，在网络程序的编写过程中，我们需要时时刻刻提醒自己面对的是各种复杂异常的场景，甚至是别有用心的攻击者，保持“防人之心不可无”的警惕。

那么程序都有可能出现哪几种漏洞呢？

### 第一个例子

```
char Response[] = &quot;COMMAND OK&quot;;
char buffer[128];

while (1) {
    int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
    if (nBytes == -1) {
        error(1, errno, &quot;error read message&quot;);
    } else if (nBytes == 0) {
        error(1, 0, &quot;client closed \n&quot;);
    }

    buffer[nBytes] = '\0';
    if (strcmp(buffer, &quot;quit&quot;) == 0) {
        printf(&quot;client quit\n&quot;);
        send(socket, Response, sizeof(Response), 0);
    }

    printf(&quot;received %d bytes: %s\n&quot;, 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);

```

### 第二个例子

第16讲中提到了对变长报文解析的两种手段，一个是使用特殊的边界符号，例如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 *) &amp;msg_length, sizeof(u_int32_t));
    if (rc != sizeof(u_int32_t))
        return rc &lt; 0 ? -1 : 0;
    msg_length = ntohl(msg_length);

    rc = readn(fd, (char *) &amp;msg_type, sizeof(msg_type));
    if (rc != sizeof(u_int32_t))
        return rc &lt; 0 ? -1 : 0;

    if (msg_length &gt; length) {
        return -1;
    }

    /* Retrieve the record itself */
    rc = readn(fd, buffer, msg_length);
    if (rc != msg_length)
        return rc &lt; 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] = &quot;just for fun\0&quot;;
strncpy(message.data, buf, strlen(buf));
if (send(socket_fd, (char *) &amp;message,
         sizeof(message.message_length) + sizeof(message.message_type) + strlen(message.data), 0) &lt; 0)
    error(1, errno, &quot;send failure&quot;);

```

就是这样一段发送端“不小心”构造的一个程序，消息的长度“不小心”被设置为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 &gt; 0 &amp;&amp; recv(fd, &amp;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-- &gt; 0) {
        if (nleft &lt;= 0) {
            int nread = recv(fd, read_buffer, sizeof(read_buffer), 0);
            if (nread &lt; 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;
}

```

这个程序的主循环在第8行，通过对length变量的判断，试图解决缓冲区长度溢出问题；第9行是判断临时缓冲区的字符有没有被全部拷贝完，如果被全部拷贝完，就会再次尝试读取最多512字节；第20-21行在读取字符成功之后，重置了临时缓冲区读指针、临时缓冲区待读的字符个数；第23-25行则是在拷贝临时缓冲区字符，每次拷贝一个字符，并移动临时缓冲区读指针，对临时缓冲区待读的字符个数进行减1操作。在程序的26-28行，判断是否读到换行符，如果读到则将应用程序最终缓冲区截断，返回最终读取的字符个数。

这个程序运行起来可能很久都没有问题，但是，它还是有一个微小的瑕疵，这个瑕疵很可能会造成线上故障。

为了讲清这个故障，我们假设这样调用， 输入的字符为`012345678\n`。

```
//输入字符为: 012345678\n
char buf[10]
readline(fd, buf, 10)

```

当读到最后一个\n字符时，length为1，问题是在第26行和27行，如果读到了换行符，就会增加一个字符串截止符，这显然越过了应用程序缓冲区的大小。

这是正确的程序，这里最关键的是需要先对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&gt; 0) {
        if (nleft &lt;= 0) {
            int nread = recv(fd, read_buffer, sizeof(read_buffer), 0);
            if (nread &lt; 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;
}

```

## 总结

今天的内容到这里就结束了。让我们总结一下： 在网络编程中，是否做好了对各种异常边界的检测，将决定我们的程序在恶劣情况下的稳定性，所以，我们一定要时刻提醒自己做好应对各种复杂情况的准备，这里的异常情况包括缓冲区溢出、指针错误、连接超时检测等。

## 思考题

和往常一样，给你留两道思考题吧。

第一道，我们在读数据的时候，一般都需要给应用程序最终缓冲区分配大小，这个大小有什么讲究吗？

第二道，你能分析一下，我们文章中的例子所分配的缓冲是否可以换成动态分配吗？比如调用malloc函数来分配缓冲区？

欢迎你在评论区写下你的思考，也欢迎把这篇文章分享给你的朋友或者同事，一起交流一下。
