数据无效的问题我在做项目的过程中也发生了,导致了服务端和客户端的崩溃。
我在测试的过程中,让客户端发送多次数据,数据的长短不一,服务端接受短数据的时候,后面总会连着一些长数据末尾的几个字符,最后才发现是结束符的问题。
一个设计良好的网络程序,应该可以在随机输入的情况下表现稳定。不仅是这样,随着互联网的发展,网络安全也愈发重要,我们编写的网络程序能不能在黑客的刻意攻击之下表现稳定,也是一个重要考量因素。
很多黑客程序,会针对性地构建出一定格式的网络协议包,导致网络程序产生诸如缓冲区溢出、指针异常的后果,影响程序的服务能力,严重的甚至可以夺取服务器端的控制权,随心所欲地进行破坏活动,比如著名的 SQL 注入,就是通过针对性地构造出 SQL 语句,完成对数据库敏感信息的窃取。所以,在网络程序的编写过程中,我们需要时时刻刻提醒自己面对的是各种复杂异常的场景,甚至是别有用心的攻击者,保持“防人之心不可无”的警惕。
下面就通过几个例子来说明一些典型的漏洞:
Example1
这个例子就包含了我遇到的问题,代码如下:
char Response[] = "it's 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”就回应“it’s OK”的字符流,乍看上去一切正常。
但仔细看一下,这段代码很有可能会产生下面的结果。
char buffer[128];
buffer[128] = '\0';
通过 recv 读取的字符数为 128 时,就会这样的结果。因为 buffer 的大小只有 128 字节,最后的赋值环节,产生了缓冲区溢出的问题。
所谓缓冲区溢出,是指计算机程序中出现的一种内存违规操作。本质是计算机程序向缓冲区填充的数据,超出了原本缓冲区设置的大小限制,导致了数据覆盖了内存栈空间的其他合法数据。
可以对这个程序稍加修改,主要的想法是留下 buffer 里的一个字节,以容纳后面的’\0’。
int nBytes = recv(connfd, buffer, sizeof(buffer)-1, 0);
这个例子里面,还昭示了一个有趣的现象。你会发现我们发送过去的字符串,调用的是sizeof,那也就意味着,Response 字符串中的’\0’是被发送出去的,而我们在接收字符时,则假设没有’\0’字符的存在。
为了统一,我们可以改成如下的方式,使用 strlen 的方式忽略最后一个’\0’字符。
send(socket, Response, strlen(Response), 0);
我遇到的情况就是,因为我用的std::string类型,直接用了str.length()来指定发送长度,导致没有发送结束符,而接收方又没有加"\0",所以出现错误;建议采取不发送结束符,在接收方加的方式,避免一些麻烦的事情发生。
Example2
在对变长报文解析中,涉及到了两种方法,一个是使用特殊的边界符号,例如 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 大小的字节(也就是前面所说的读取操作不会成功)。
总结
在网络编程中,需要多注意异常边界的检测问题,因为这些问题决定了程序运行的稳定性,不要动不动就崩了。要时刻提醒自己做好应对各种复杂情况的准备,这里的异常情况包括缓冲区溢出、指针错误、连接超时检测等。
问题一:在读数据的时候,一般都需要给应用程序最终缓冲区分配大小,这个大小咋确定?
最终缓冲区的大小应该比预计接收的数据大小大一些,预防缓冲区溢出。
问题二:这里的缓冲区是否可以换成动态分配?
可以的,但是要记得在return前释放缓冲区。我项目程序中写到的就是根据接收数据的大小来分配,当时是直接new的,没有考虑到释放(后来缓冲区出错了,字符读取错乱,才注意到)。