级别: 初级 杨 广翔 (yang.guangxiang@gmail.com), 高级软件工程师, 中兴通讯
2009 年 7 月 23 日 通过在 Linux 的 ICMP 协议栈中嵌入一个私有的类 telnet 服务(称为 ktelnetd),开发人员可以从外部连接到嵌入式设备的内核中,并利用预置的命令检查内核的参数,甚至调整运行状态。这种方法有助于开发人员诊断和定位系统的异常。
内核中的 telnet 服务
开发人员依赖串口对嵌入式 Linux 设备进行调试和开发。串口是开发人员与设备之间的操作界面。依靠它,开发人员获得与操作系统交互的能力。对于拥有网络界面的硬件设备来说, telnet 是另一个选择。通过 telnet 接入到设备将使开发人员获得与串口界面相同能力。
但是,当内核中的代码(如驱动)产生无限循环或其它耗时操作时,操作系统将无法调度和处理串口界面上的输入。串口界面将停止响应开发人员的操作。一些其它的异常也会造成串口无法工作。同时,出现这些问题时刻, telnet 服务也往往停止了工作。开发人员无法通过 telnet 界面来接入设备,也就无力进行任何调试操作了。
在实际的开发环境中,出现上述类似问题时,运行在设备上的 Linux 操作系统仍然可以响应外部的 PING 请求。这意味着操作系统的 ICMP 协议栈仍然可以正常工作。那么,如果可以将 telnet 服务植入 Linux 操作系统的 ICMP 协议栈中,我们就可以得到一个可靠性更高的接入界面。另一方面,由于 ICMP 协议栈是运行在 Linux 内核中,那么在协议栈中的 telnet 服务将不会受到任何限制,而获得访问内核的能力。通过这个接入界面,开发人员可以自由的访问内核中的数据,甚至调整内核中的某些参数和运行状态。这个方式要比 proc 界面方便了许多。
为了植入 telnet 服务,我们首先需要了解 Linux 的 ICMP 协议栈的工作原理。在这之前,简单的介绍 ICMP 协议是有必要的。
ICMP 是 IP 协议的一个组成部分。 ICMP 报文使用 IP 数据报 ( UDP ) 的方式传输。 ICMP 报文中所含的 IP 头部中需要注明协议类型。图 1 是 IP 头部的结构图。
图 1. IP 头部
如图 1 所示,对于 ICMP 报文,位于 IP 头部第 10 个字节的“协议”字段必须为 1 。这指明了协议类型为 ICMP 。在内核代码中,这个类型被定义为 IPPROTO_ICMP 。 IP 头部后面的载荷为 ICMP 报文。 ICMP 报文的格式见图 2 。
图 2. ICMP 头部
所有 ICMP 报文的前 4 个字节即为 ICMP 头部。它们总是相同的,即类型、代码和检验和。 ICMP 报文有多种类型。 PING 请求属于“请求回显”类型。这个类型的值被定义为 8 。在内核代码中则定义为 ICMP_ECHO 。在 PING 请求中,ICMP 头部之后就是净载荷。收到 PING 请求的主机通过将净载荷原封不动的回应回去来声明通信链路处于正常工作状态。回应的 ICMP 报文的类型为 ICMP_ECHOREPLY ,其值为 0 。
在 Linux 操作系统中,ICMP 协议栈的代码位于目录 /linux-2.6.x/net/ipv4 中的 icmp.c 文件。这部分代码属于 Linux 操作系统的网络部分。
Linux 对于 PING 请求的简明处理流程是这样的:
函数 icmp_rcv 在收到被封装到 SKB 结构中的报文后,获取到 ICMP 头部。根据头部中携带的类型,调用相对应的处理函数。代码如下:
int icmp_rcv(struct sk_buff *skb)
{
……
icmp_pointers[icmph->type].handler(skb);
……
} |
对于 PING 请求来说, icmph->type 的值为 ICMP_ECHO ,也就是 8 。对应的处理函数即是 icmp_echo 。 在 icmp_echo 函数中,修改收到的 SKB 结构中的 ICMP 头部,使其类型为 ICMP_ECHOREPLY 。然后,调用 icmp_reply 函数,回应收到的 PING 请求。
从整个流程来看, Linux 对于 PING 请求报文的处理是较为简单的。这就为我们植入 telnet 服务带来了便利。
RFC854 定义了 telnet 协议的规范。 telnet 协议基于 TCP 传输协议。因此, telnet 协议无法平滑的移植到 ICMP 协议栈中。定义一个私有的协议是唯一的选择。为了可以在 ICMP 协议栈中实现 telnet 服务,这个协议必须基于数据报( UDP ),而且,这个协议必须基于 ICMP 协议,并且使用 ICMP_ECHO 类型。在这个限制条件下,我们可以利用这个类型的 ICMP 报文中的净载荷来承载私有协议。
我们定义的私有协议的结构是:
图 3. 私有协议结构 - 请求
请求报文中,除去 4 个字节的 ICMP 报文头部,前两个字节是 magic 数,用来标明报文是我们的私有协议。这个 maigc 可任意定义,比如是两个 ASCII 码字符“ LX ”。后两个字节指示命令的长度。在长度字段之后,是一个字符串。它是客户端请求执行的命令。以字符串的形式存储在长度字段之后,并以 0 为结束符。
与之对应的是,回应报文中携带的是字符串形式的命令执行结果。如下图:
图 4. 私有协议结构 - 回应
由于利用这个私有协议实现的类 telnet 服务是在内核中实现的。因此,这个私有协议被命名为 ktelnet 协议。
完整的系统由三个部分组成: ktelnet 、 ktelnetd 和 kshell 。
ktelnet 运行在客户端,开发人员使用它接入 ktelnetd 。通常情况下,ktelnet 是一个运行在 windows 的可执行程序。
ktelnetd 运行在嵌入式设备上,接收来自 ktelnet 的报文,并从报文解析出命令。
kshell 是一个逻辑模块。它与 ktelnetd 一起运行在内核中,负责执行 ktelnetd 提交的命令。执行结果将反馈给 ktelnetd 。 ktelnetd 将执行结果返回给 ktelnet 。
ktelnetd 是 ktelnet 的服务端。 Kshell 则是 ktelnetd 的后台支持模块。 ktelnetd 和 kshell 与 ICMP 协议栈一起运行在 Linux 内核中。
这样的一个系统其执行过程如下图所示:
图 5. 系统工作流程
Ktelnet 在启动后,生成和维护一个 CLI (命令行)界面。它利用这个界面接收用户输入的命令,再将命令按上一节描述的 ktelnet 协议格式封装成 ICMP 报文发送给 ktelnetd 。
ktelnted 接收来自 ktelnet 的报文,将从报文中解析出的命令提交给 kshell 。 kshell 维护着一个命令列表。这个列表中记录了命令名称和相应的执行函数。 kshell 根据 ktelnetd 提交的命令,调用相应的函数,完成这个命令的执行任务。执行的结果以字符串的形式保存在内存中。 ktelnetd 在命令执行完毕后,生成一个 ICMP_ECHOREPLY 报文,将执行结果封装到报文中,回应给 ktelnet 。
ktelnet 从收到的报文中解析出执行结果,并将结果输出在 CLI 界面上。由于命令和执行结果都是以字符串的形式表现的,因此,在整个过程中,用户的使用感受与 telnet 服务一样。
在通常的应用环境中,ktelnet 应基于 Windows 。即使是 Linux 开发人员也往往使用 Windows 做为客户端。
ktelnet 首先需要初始化 winsock 库,创建一个原始( RAW )的 socket 。
WSADATA wsd;
SOCKET s;
/* init windows socket libarary */
if (WSAStartup(MAKEWORD(2,2), &wsd)!=0)
perror ("init fail\n");
if ((s = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)) < 0)
{
perror("ktelnet: socket fail\n");
return -1;
} |
然后,输出一个提示符(#),等待用户输入命令。
char cmd[128], c;
printf("#"); /* print prompt */
i = 0;
memset(cmd, 0, sizeof(cmd));
while ( (c=getchar()) != '\n' && (i < (sizeof(cmd)-1)))
{
cmd[i++] = c;
} |
当用户结束命令的输入按下回车键后,保存在字符数组 cmd 中的数据需要被封装到 ICMP 报文中发送出去。我们定义了下面所示的 ICMP 头部。
struct icmphdr {
__u8 type;
__u8 code;
__u16 checksum;
union {
struct {
__u16 id;
__u16 sequence;
} echo;
__u32 gateway;
struct {
__u16 __unused;
__u16 mtu;
} frag;
} un;
}; |
利用这个头部结构,我们可以构造出一个 ICMP_ECHO 类型的报文,再将用户输入的命令存入这个报文的净载荷中。
struct icmphdr *icp;
struct sockaddr whereto;
icp = (struct icmphdr *)buff;
icp->type = ICMP_ECHO; /* TYPE */
icp->code = 0;
icp->checksum = 0;
icp->un.echo.id = 0x400; /* ID */
/* compute ICMP checksum here */
icp->checksum = in_cksum((u_short *)icp, size);
payload = buff + sizeof(struct icmphdr); /* payload of icmp */
payload[0] = 'L'; /* magic */
payload[1] = 'X';
*((short *)(payload+2)) = htons(strlen(cmd)); /* length */
strcpy(&payload[4], cmd); /* command */
i = sendto(s, (char *)buff, size, 0, &whereto, sizeof(struct sockaddr)); |
ktelnetd 执行了命令后,执行结果仍使用 ICMP 报文发送回来。 ktelnet 需要解析这些回应报文。
struct iphdr {
__u8 ihl:4,
version:4;
__u8 tos;
__u16 tot_len;
__u16 id;
__u16 frag_off;
__u8 ttl;
__u8 protocol;
__u16 check;
__u32 saddr;
__u32 daddr;
/*The options start here. */
};
struct iphdr *ip;
/* Check the IP header */
ip = (struct iphdr *)buff;
hlen = ip->ihl << 2; /* head length */
payload = buff + hlen + sizeof(struct icmphdr);
/* check magic */
if (payload[0] != 'L' || payload[1] != 'X')
return -1;
printf("%s", payload+4); /* print result */ |
将命令的执行设计为一个逻辑模块是为了便于理解和方便未来命令的扩展。 kshell 维护了一个命令列表。这个列表的结构为:
typedef void (*KSHELL_CMD)(char *cmd, char *r, int size);
struct {
char name[128];
KSHELL_CMD fn;
}kshell_cmd_set[32]; |
name 中保存了命令名称,而 fn 则是这个命令的处理函数。一个典型的预置命令是 type 。它的语法是:
这个命令的处理函数 kshell_cmd_type 将根据 addres 参数取出该地址的数据。数据被转换为字符串存入全局变量中。
将 type 命令加入列表中的方法是:
strcpy(Kshell_cmd_set[0].name, “ type ” );
kshell_cmd_set[0].fn = kshell_cmd_type; |
命令列表记录了各个命令的名称和处理函数。只需要在这个列表中增加新的命令,kshell 的功能就得到了扩充。只要 kshell 向外部暴露一个扩展接口,开发人员就可以将自己设计的命令功能添加到 kshell 中。 kshell 的设计目标就是为了便于扩展。
ktelnetd 被植入 ICMP 协议栈,因此,它的代码是分散的。主要集中在 icmp_rcv 函数和 icmp_echo 函数中。
首先,ktelnetd 需要在 icmp_rcv 函数中过滤 ICMP 报文。当发现带有 magic 的 ICMP_ECHO 报文时, ktelnetd 从报文中过滤出 ktelnet 请求执行的命令。
下面的代码放置在代码 icmp_pointers[icmph->type].handler(skb); 之前。
#define KTELNET_HEAD_SIZE 4
/* filter ktelnet request */
if (icmph->type == ICMP_ECHO && \
skb->data[0] == 'L' && skb->data[1] == 'X'){
cmd_len = ntohs(*(short *)(skb->data+2)); /* command length */
memset(ktelnet_cmd, 0, sizeof(ktelnet_cmd));
strncpy(ktelnet_cmd, skb->data+KTELNET_HEAD_SIZE, cmd_len);
/* execute the command */
l = kshell_main(ktelnet_cmd); /* execute and save result */
ktelnet_req = 0;
ktelnet_req = 1;
} |
我们定义的私有协议 ktelnet 的报文头部是 4 个字节。首先检查头部中的 magic 。然后就可以获得命令的长度信息,最后,将过滤出的命令保存在变量 ktelnet_cmd 中。通过 kshell_main 函数这个变量被提交给 kshell 。
在 kshell_main 函数中,kshell 检索 kshell_cmd_set[32] 列表,找到命令对应的处理函数,并调用函数。在处理函数的执行过程中,命令执行结果被存入全局变量 ktelnet_result 中。
在命令被成功执行之后,ktelnetd 置下全局标志 ktelnet_req 。这样,icmp_echo 函数就可以了解到当前正在处理的报文是 ktelnet 协议。
在 icmp_echo 函数的一开始,就执行下面的 ktelnetd 代码:
/* set magic */
skb->data[0] = 'L';
skb->data[1] = 'X';
*(short *)(skb->data+2) = strlen(ktelnet_result); /* set length */
/* copy result */
strcpy(skb->data + KTELNET_HEAD_SIZE, ktelnet_result);
/* clear mark */
ktelnet_req = 0; |
命令的执行结果 ktelnet_result 被封装到 SKB 结构中,icmp_echo 函数最后调用 icmp_reply 函数将回应报文发送出去。
在植入了 ktelnetd 后,开发人员通过 ktelnet 客户端可以接入嵌入式设备的内核。开发人员也可以根据自己的需要扩展各种命令,诊断或调试内核。前文曾提及命令 type 。以此命令为例,开发人员在客户端的控制台上执行:
C:\>ktelnet 192.168.1.1
#
#type 0xc051dc40
1
# |
0xc051dc40 是内核全局变量 num_processors 的地址,表示处理器数量。这个数据可以通过 system.map 查询得到。
从这个例子可以看出 ktelnetd 将帮助开发人员获得访问内核的能力。通过对 kshell 支持命令的扩展,我们将得到更加丰富的功能。
此外,实验也表明,当一个 I/O 驱动进行无限循环之类消耗 CPU 的行为时,ktelnetd 仍可以正常工作。这无疑有助于开发人员的定位和诊断。
下载 | 描述 | 名字 | 大小 | 下载方法 |
|---|
| 样例代码 | icmp.c | 38KB | HTTP |
|---|
参考资料
关于作者  | |  | 本文作者 杨广翔 毕业于武汉大学。他是中兴通讯的高级软件工程师,目前在南京中兴通讯从事嵌入式 Linux 产品的开发工作。可以通过 yang.guangxiang@gmail.com 与他联系。 |
对本文的评价
|