UCAS CTF

C语言进阶:字符串

Author: doyo

C风格字符串

所谓字符串,顾名思义,就是一连串不间断的字符(注意,不间断不意味着不能有空格,事实上空格也是一种特殊的字符)。如果你学过OI,你可能经常使用一种“string”类型的字符串。但是,string是C++中才有的,在C语言中用不了。

在C语言中,我们使用的是一种被称之为“C风格字符串”的形式来描述字符串,即使用一个以“\0”结尾的char类型数组来存放字符串,例如:

char str[] = "Hello World!\0";

char类型用于定义字符变量。一个char类型变量占用一个字节(即1B,8bit)的内存空间,可以存放一个ASCII字符。这里的“\0”是一个字符,前面的“\”是一个转义符,表示它后面的一个字符(例如这里的“0”)不表示其原本的意思,而是一个特殊含义(例如“\n”不表示小写字母n而是换行)。所以,“\0”表示的不是一个ASCII码为0x30的字符“0”,而是一个ASCII码为0x00的空字符。所以,我们也可以说“C风格字符串”是以空字符为结尾的字符串。

一般情况下,我们也常常会省略字符串末尾的这个“\0”,因为在计算机内部存储字符串常量时会自动为其添加“\0”,所以,我们更常使用的是下面这种等价的定义方式:

char str[] = "Hello World!";

这个字符串长度为12(10个英文字母+1个空格+1个叹号),但实际需要占用13B的内存空间,因为“\0”也要占用1B的字节。

字符串的读入

C语言中,比较常见的有四种方式:scanf()函数、gets()函数、read()函数和getchar()函数(我知道有同学会用cin,但那是C++才有的东西)。

scanf()函数

用法与读入整数时类似:

char str[100];          // 给要读入的字符串留够空间!
scanf("%s", str);

我们使用占位符“%s”来表示字符串。此处我们传入的第二个参数str已经是一个指针了,所以我们不需要再对它取一次地址。

使用上述方法时,默认读入的字符串以空格为结尾,毕竟你不可能在终端中输入空字符。但这也导致它在处理字符串读入时可能不够灵活(例如它必须用两个“%s”占位符才能读入“Hello World!”);同时,它也没有对读入字符串的长度做出限制(想想看,这会不会存在什么隐患?)。

gets()函数

一次读入一整行:

char str[100];
gets(str);

上例中,将一整行字符串(即一个以“\n”作为结尾的字符串)读入到了str指向的数组中。注意,读入后,str[]存储的字符串中不会包含“\n”。

gets()函数是具有返回值的,它会在读入成功时返回指向读入字符串的指针(跟传入的参数相同),在发生错误或到达文件末尾时返回一个空指针。可以根据其返回值来判断是否完成了文件的读入。

同样,你也许注意到了,gets()函数也没有限制读入字符串的长度。

read()函数

用于从文件中读取字符串,函数原型如下:

ssize_t read(int fd, void *buf, size_t count);

其中,参数fd是一个文件描述符(这是《操作系统》的内容),目前你可以简单地认为它是一个指向文件的指针;buf是你要存放读取内容的数组;count是你要读取的字符数。这个函数在读取成功时返回读取的字符数(这个值可能比count小,因为你可能提前读到了文件末尾),在失败时返回-1

用例如下:

FILE *fd = fopen("/path/to/file", "r");         // 以只读权限打开文件

if (fd == NULL) {                               // 检查文件是否成功打开
    printf("Failed to open file!\n");
} else {
    char str[100];
    int ret = read(fd, str, 99);

    if (ret == -1) {
        printf("Failed to read from file!\n");
    }

    fclose(fd);                                 // 关闭文件
}

getchar()函数

所以,我们有时也会使用getchar()函数作为替代,例如:

void myRead(char *str) {                        // 将字符串读入到从str开始的一段内存中
    int i = 0;
    char c;     
    while ((c = getchar()) != '\n') {           // 检查读入的字符是否等于约定的字符串结尾字符,这里是'\n'
        str[i++] = c;
        if (i >= 99) {                          // 检查字符串长度是否超出了可接受的范围
            while ((c = getchar()) != '\n')
                ;                               // 空循环,清空输入缓存,防止影响下一次读入
            break;
        }
    }
    str[i] = '\0';                              // 注意字符串总是以'\0'作为结束
}

上面这种方法要灵活的多,因为它可以以你想要的字符来区分输入中的不同字符串,例如上例中展示的便是以“\n”来进行区分。你也可以使用其它字符,但注意,使用的字符必须是你能通过键盘输入的字符。

字符串的输出

字符串的输出要简单的多,使用printf()函数即可,程序会在遇到“\0”时自动停止输出:

printf("%s", str);

也可以使用puts()函数,这个函数的不同之处在于它会自动在字符串输出结束后进行换行:

puts(str);

string.h与常见字符串处理函数

常见的字符串处理函数几乎都可以在string.h中找到。

获取字符串长度

字符串最基本的操作之一。

函数原型:

size_t strlen(const char *str)

该函数传入一个参数str,为目标字符串;返回一个整数值,表示str的长度(不计入字符串结尾的“\0”)。

点击此处下载示例代码strlen.c。

比较字符串

字符串也是可以比较的,strcmp()将两个字符串从左至右依次比较ASCII值大小,直至出现不同字符或到达某一字符串结尾(“\0”)。

函数原型:

int strcmp(const char *str1, const char *str2)

传入的两个参数是我们要比较的两个字符串;返回值是一个有符号整数,满足:

点击此处下载示例代码strcmp.c。

连接字符串

连接字符串是指将两个字符串拼接在一起,例如将“Hello ”(注意结尾有空格)和“World!”拼成“Hello World!”。

函数原型:

char *strcat(char *dest, const char *src)

该函数将字符串src拼接在dest字符串的末尾;返回指向拼接得到的字符串的指针(其实就是dest)。

点击此处下载示例代码strcat.c。

如果你足够仔细和谨慎,你或许会发现这个函数有个致命的漏洞:没有限制字符串的长度。这可能导致拼接后字符串长度超出了为它预设的内存空间大小。

例如,dest字符串为“Hello ”,但我们只为它预留了7B的内存空间(刚好放下这个6个字符和作为结尾的“\0”);src字符串为“World!”;显然,此时将src拼接到dest之后,dest的长度便超出了我们实际允许它使用的内存空间大小。

上述这类问题就是大名鼎鼎的缓冲区溢出(buffer overflow),缓冲区就是指我们在内存中划定的一块特定大小的区域(你也可以简单地理解成一个数组)。缓冲区溢出可以造成非常严重的危害。借由这一漏洞,攻击者可以非法覆写内存(不要忘记,任何数据在内存都以0和1的形式存在),从而破坏数据乃至改变程序行为。

在上述实例代码中,我们特意将字符串占用空间声明得很小,并且移除了对读入字符串长度的检查,大家可以尝试通过溢出攻击,通过strcat修改第二个字符串的数据。

在CTF中,缓冲区溢出问题也是PWN方向重点研究的问题之一,我们会在相关课程中对这类问题作更深入的探讨,敬请期待。

介于缓冲区溢出问题的存在,我们更推荐采用下面这个函数来进行字符串拼接:

char *strncat(char *dest, const char *src, size_t n)

这个函数引入了一个新的参数n,用来限制要追加的最大字符数。通过合理设置这个参数,可以有效应对缓冲区溢出的问题。

复制字符串

函数原型:

char *strcpy(char *dest, const char *src)

src处的字符串复制到dest处;返回一个指向dest的指针。

点击此处下载示例代码strcpy.c。

这个函数与前文所述的strcat()类似,也存在缓冲区溢出问题,因此,我们更推荐采用下面这个函数来进行字符串复制:

char *strncpy(char *dest, const char *src, size_t n)

类似地,第三个参数n用来限制能复制的最大字符数。

字符串查找

函数原型:

char *strstr(const char *haystack, const char *needle)

haystack中查找needle是否出现,若是,返回指向第一次找到needle时的位置的指针;否则,返回空指针。

点击此处下载示例代码strstr.c。