极客时间已完结课程限时免费阅读

18 | 防人之心不可无:检查数据的有效性

18 | 防人之心不可无:检查数据的有效性-极客时间

18 | 防人之心不可无:检查数据的有效性

讲述:冯永吉

时长10:14大小9.38M

你好,我是盛延敏,这里是网络编程实战第 18 讲,欢迎回来。
在前面一讲中,我们仔细分析了引起故障的原因,并且已经知道为了应对可能出现的各种故障,必须在程序中做好防御工作。
在这一讲里,我们继续前面的讨论,看一看为了增强程序的健壮性,我们还需要准备什么。

对端的异常状况

在前面的第 11 讲以及第 17 讲中,我们已经初步接触过一些防范对端异常的方法,比如,通过 read 等调用时,可以通过对 EOF 的判断,随时防范对方程序崩溃。
int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
if (nBytes == -1) {
error(1, errno, "error read message");
} else if (nBytes == 0) {
error(1, 0, "client closed \n");
}
你可以看到这一个程序中的第 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 *) &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 {
error(1, errno, "error read message");
}
} else if (nBytes == 0) {
error(1, 0, "client closed \n");
}
...
}
这个代码片段在第 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(&allreads);
FD_SET(socket_fd, &allreads);
for (;;) {
readmask = allreads;
int rc = select(socket_fd + 1, &readmask, NULL, NULL, &tv);
if (rc < 0) {
error(1, errno, "select failed");
}
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) {
error(1, errno, "error read message");
} else if (nBytes == 0) {
error(1, 0, "client closed \n");
}
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);

第二个例子

第 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 *) &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)
error(1, errno, "send failure");
就是这样一段发送端“不小心”构造的一个程序,消息的长度“不小心”被设置为 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) {
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;
}
这个程序的主循环在第 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> 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;
}

总结

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

思考题

和往常一样,给你留两道思考题吧。
第一道,我们在读数据的时候,一般都需要给应用程序最终缓冲区分配大小,这个大小有什么讲究吗?
第二道,你能分析一下,我们文章中的例子所分配的缓冲是否可以换成动态分配吗?比如调用 malloc 函数来分配缓冲区?
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起交流一下。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 7

提建议

上一篇
17 | TCP并不总是“可靠”的?
下一篇
19丨提高篇答疑:如何理解TCP四次挥手?
unpreview
 写留言

精选留言(25)

  • LDxy
    2019-09-12
    1,最终缓冲区的大小应该比预计接收的数据大小大一些,预防缓冲区溢出。2,完全可以动态分配,但是要记得在return前释放缓冲区

    作者回复: 👍

    29
  • 郑祖煌
    2020-07-02
    (1).第一道,我们在读数据的时候,一般都需要给应用程序最终缓冲区分配大小,这个大小有什么讲究吗? 有讲究的。如果分配的太小,那就会频繁的从用户太切换到内核态,这样其实非常损耗CPU的时间。同时如果设置的太大的话,那就会长期阻塞在read或者recv函数上,造成可以先服务或者先完成的内容没完成。再次,也得比实际的数据稍微大一些以免缓冲区溢出,边界的问题要想办法做好的调整。。 (2). 第二道,你能分析一下,我们文章中的例子所分配的缓冲是否可以换成动态分配吗?比如调用 malloc 函数来分配缓冲区?是可以动态分配,就是new的话要记得及时的去delete,以免造成内存泄露。
    展开

    作者回复: 回答的这么赞,我还能说啥呢 :)

    20
  • 超大红细胞
    2020-01-11
    一开始不理解为什么设置了 timeout 的 recv 会返回 EAGAIN 错误,在我的知识体系中 EAGAIN 一般出现在非阻塞的 socket 中,后来 man 了一下 SO_RCVTIMEO,发现确实如此,给后面的同学提个醒: Specify the receiving or sending timeouts until reporting an error. The argument is a struct timeval. If an input or output function blocks for this period of time, and data has been sent or received, the return value of that function will be the amount of data transferred; if no data has been transferred and the timeout has been reached then -1 is returned with errno set to EAGAIN or EWOULDBLOCK, or EINPROGRESS (for connect(2)) just as if the socket was specified to be nonblocking. 总之一句话,SO_RCVTIMEO 会导致 recv 返回 EAGAIN
    展开

    作者回复: 👍

    16
  • 传说中的成大大
    2019-09-15
    第一问: 不能太小也不能太大 太小了频繁的用户态和内核态切换,太大了读不够容易阻塞,就算不阻塞也容易浪费 第二问: 如果用malloc频繁的申请和释放也不太好 容易造成碎片
    共 2 条评论
    4
  • yusuf
    2019-09-11
    1、大小一般为2的多少次方 2、不能换成动态分配。在read中需要sizeof指明接受数据的最大长度,malloc返回的是一个指针,求指针的sizeof时返回的是指针所占内存大小(32位为4,64位为8),跟实际数据的大小不一致
    共 3 条评论
    3
  • 郭晓朋
    2021-01-28
    你好,对于最后一个例子我感觉好像有问题,--length这种写法会读不到012345678\n这个字符串的,最终导致读到的字符串没有结束符\0。length--导致越界的原因是*buffer++ = c;。不应该先执行buffer++,应该放到if语句之后。

    作者回复: 你是说样例的readline程序有问题么?

    2
  • J.M.Liu
    2019-09-12
    老师,第二个例子中,及时加上了msg_length和缓冲区length的大小比较,如果msg_length写得很大(但小于length)而实际数据没有那么大时,服务器也会阻塞在read上吧?所以说判断msg_length<=length并不能接read阻塞的问题呀,只能解内存溢出的问题。

    作者回复: 是的,如果是这样,只能说我们双方的通信协议没有得到严格的遵守。

    2
  • 黄毅
    2021-04-10
    关于思考题的第二个问题,有个疑问,如果通过动态分配read_buffer,假设recv能读取512个字节,进一步假设第200个字符是\n,那么在read_line退出前能delete吗?如果delete的话,会不会第201到512字节的数据丢失?

    作者回复: 这要看你的程序是怎么设计的,如果是预先读取了512字节,那确实200后的数据,你得想办法重新"缓冲"起来,以便和后面的数据拼凑成完整意义的字节流;如果你是一个一个字节读的,知道读到\n,那么则不必了。 一般情况下,我们是用缓冲来做这件事情,你可以跳到后面的Buffer设计部分,通过指针来指向当前消费的位置,和数据读取的位置做一个比较,就可以不用每次重新生成动态分配的buffer。

    1
  • lupguo
    2020-01-12
    课后题,我的理解是应用程序缓冲区大小设置,如果是预分配buffer情况,应该根据程序场景来的。这个buffer是通过recv系统调用从内核空间将数据拷贝到用户空间,如果buffer过小,会导致需要多次系统调用的开销,过大又会导致用户空间浪费,因此需要选择合适大小;第二个是可以选择动态malloc,只是需要注意释放,否则会有内存泄露的风险;当然也可以基于gc机制来统一回收!
    1
  • 2019-11-24
    😅读完好像明白了,其实应该没明白,因为,课后思考题,答不上来! 在网络编程中,是否做好了对各种异常边界的检测,将决定我们的程序在恶劣情况下的稳定性,所以,我们一定要时刻提醒自己做好应对各种复杂情况的准备,这里的异常情况包括缓冲区溢出、指针错误、连接超时检测等。
    1
  • Khan
    2023-02-06 来自湖南
    看到好多人说缓冲区太大会阻塞,不是很理解,读取数据的时候又不是一定要读满整个buffer,而是读取消息头中指定的数据大小而已,所以应该不存在阻塞吧?
  • 无名氏
    2022-05-22
    临时缓存区,那个“微小瑕疵”,前后两段程序程序没有区别啊😄?

    作者回复: 你细品: int nBytes = recv(connfd, buffer, sizeof(buffer), 0); int nBytes = recv(connfd, buffer, sizeof(buffer)-1, 0);

  • 纪神籽
    2022-03-31
    第三个例子有点疑问,如果第一次调用readline,读取512字节,查到第100字节是换行符,然后就返回结果,这样子剩下的412字节不就会丢失了。是不是应该先把上次readline的数据处理完在进行新的recv。

    作者回复: 你说的没错,是需要把剩下的412字节保存下来的。这个例子只是对readline该注意的事项进行了模拟分析。

  • 苏志辉
    2021-10-29
    第三个例子修改后的版本,如果length为10发送的也是012345678\n一共10个,由于--length,所以一次最多读9个,要读完完整的消息,需要读两次吧

    作者回复: 读取的时候,是一次性尝试读取最多 512 字节。 最外面的length循环实际上控制的是拷贝临时缓冲区字符的工作。 所以,还是读取一次。

  • 吃猫的鱼
    2021-08-06
    第三个例子中,如果recv读取出的数据格式如下 “xxx\n yyy\n”,然后length=4,此时在不就只读出 “xxx\n”,但是下一次调用recv,“yyy\n”没被应用就被丢弃了。。。

    作者回复: 这个例子的假设就是\n为报文的分界符。

  • null
    2021-04-05
    原文: 这个函数一次性读取最多 512 字节到临时缓冲区,之后将临时缓冲区的字符一个一个拷贝到应用程序最终的缓冲区中。 在程序的 26-28 行,判断是否读到换行符,如果读到则将应用程序最终缓冲区截断,返回最终读取的字符个数。 问题: 假设 client 在一个连接中,发了2次请求: 第一次发了1000个字符(包含换行符); 第二次发了24个字符(包含换行符)。 对端 server 在 socket 缓冲区,每次都读取 512 个字符。读到换行就截断。那第二次请求的24个字符不就被截断丢弃了,应用程序再也读不到这24字符了。 请问老师,会出现这种情况么?谢谢
    展开

    作者回复: 不会啊,因为readline这个函数是被反复调用的,所以会读到第二行(也就是第二个换行符)。

  • NEVER SETTLE
    2020-07-20
    “第二个版本,这个函数一次性读取最多 512 字节到临时缓冲区,之后将临时缓冲区的字符一个一个拷贝到应用程序最终的缓冲区中“ 老师,针对第二个版本,我有个问题,如果“\n”之后还会有内容,当调用recv函数后,假如返回512个字符,但是程序只取了“\n”之前的字符串,那“\n”之后的内容是不是就是丢弃了。

    作者回复: 原来问题在这啊。 不会啊,可以继续调用recv函数来读下一个512字节。在网络字节流处理中,循环读取是一个常见的技巧。

    共 3 条评论
  • NEVER SETTLE
    2020-07-20
    “第二个版本,这个函数一次性读取最多 512 字节到临时缓冲区,之后将临时缓冲区的字符一个一个拷贝到应用程序最终的缓冲区中。“

    作者回复: 没看懂问题.....

  • YC
    2020-07-20
    //输入字符为: 012345678\n char buf[10] readline(fd, buf, 10) 为什么会溢出呢?不应该刚刚好填满buffer嘛。 0123456789\n才会溢出吧
    展开

    作者回复: 注意是增加一个\0哦,原来的换行符还是会读取的,所以会溢出的。

  • 满怀
    2019-12-22
    老师能帮我看一下代码吗 我把这段时间学习到的 整合在一起 现在执行的时候出了一点问题 分析不出错误原因

    作者回复: 抱歉,这段时间一直忙,贴下你的代码链接

    共 2 条评论