跳到内容
Shiny's Blog

字符串

上一章学了数组的汇编形态。这一章学字符串:C 里写 char str[] = "Hi"strcmp(a, b),编译器翻译成什么。

和前几章一样,编译 Debug x86,用 x64dbg 断到函数对照。汇编只保留字符串相关的核心指令,过滤掉 Debug 噪音。

字符串就是 char 数组

C 语言的字符串就是\0(字节 0)结尾的 char 数组。没有长度字段,没有边界检查,就是一段连续字节最后一个 0。

char str[] = "Hi";

内存中:

地址    值    含义
str+0   0x48  'H'
str+1   0x69  'i'
str+2   0x00  '\0'  ← 结尾标记

访问 str[i] 和上一章的 char 数组完全一样,SIB 寻址 scale=1,byte ptr 取值。字符串唯一的特征是末尾有 \0,所有字符串操作都靠扫描到 \0 来判断结束。

ASCII 和编码

上一节说字符串就是 char 数组,但 char 数组里每个字节到底代表什么字符?这取决于编码。同一个字节在不同编码下可能代表不同字符,逆向时必须先判断程序用的是什么编码。

ASCII

ASCII 是最早的字符编码,用 1 个字节表示 128 个字符(0x00-0x7F)。英文字母、数字、标点都在这个范围内。

'A' = 0x41    'a' = 0x61    '0' = 0x30    '\0' = 0x00

ASCII 只覆盖英文,中文、日文等其他语言的字符不在其中。

ASCII 只定义了 0x00-0x7F 这 128 个码位,一个字节有 8 位,0x80-0xFF 这一半空着。于是各种编码都在这一半上做文章,衍生出很多”扩展 ASCII”:ISO-8859-1(西欧语言,加入了带重音的字母和 € 符号)、Windows-1252(微软版 ISO-8859-1)、GBK(中文)等等。它们都兼容 ASCII 的 0x00-0x7F,区别只在 0x80-0xFF 怎么用。逆向时看到字节全在 0x00-0x7F,就是纯 ASCII 英文;看到 0x80 以上的字节,就要根据程序来源判断是哪种扩展编码。

GBK(中文 Windows 常用)

GBK 用 2 个字节表示一个中文字符。char 数组里一个中文占 2 字节,英文还是 1 字节。MSVC 在 Windows 中文环境下默认用 GBK 编码源文件中的字符串字面量。

GBK 的两个字节范围不一样:

  • 首字节0x81-0xFE(必定 ≥ 0x80,最高位是 1,和 ASCII 的 0x00-0x7F 完全不重叠)
  • 尾字节0x40-0xFE(不含 0x7F),范围比首字节多出 0x40-0x80 这一段

也就是说,尾字节可能落在 ASCII 范围内。比如 0x81 0x40 是一个合法的 GBK 字符,第二个字节 0x40 看起来像 '@'。所以判断 GBK 中文不能只看”有没有字节 ≥ 0x80”,必须从首字节开始两个两个地读。

char msg[] = "你好Hi";

内存中(GBK):

地址    值         含义
msg+0   0xC4 0xE3  '你'(首字节 0xC4,尾字节 0xE3,都在 0x81-0xFE)
msg+2   0xBA 0xC3  '好'(首字节 0xBA,尾字节 0xC3,都在 0x81-0xFE)
msg+4   0x48       'H'(1 字节,ASCII)
msg+5   0x69       'i'(1 字节,ASCII)
msg+6   0x00       '\0'

逆向时在 x64dbg 内存窗口里,看到一个字节 ≥ 0x81,它不可能是 ASCII,一定是 GBK 字符的首字节。从首字节开始,连读 2 个字节得到一个中文字符,再继续往后扫。

GBK 和 GB 18030 的关系

GBK 是 1995 年的规范,不是正式国标。2000 年国家推出了 GB 18030 作为强制标准取代 GBK。GB 18030 是 GBK 的超集:完全兼容 GBK 的双字节编码,还增加了 4 字节编码以覆盖 Unicode 全部字符。实际逆向 Windows 程序时,MSVC 在中文 Windows 上默认用 CP936(基于 GBK),所以日常见到的字符串大多是 GBK 编码。遇到生僻字 GBK 编不下才会用到 GB 18030 的 4 字节部分。

Unicode 与 UTF-8

Unicode 是国际通用的字符集,给世界上每个字符分配一个唯一编号(码点)。UTF-8 是 Unicode 的编码方式之一,用变长字节存储:ASCII 字符 1 字节,中文 3 字节。现代程序(尤其跨平台的)大多用 UTF-8。

// UTF-8 编码下
char msg[] = "Hi你";
// 内存:0x48 0x69 0xE4 0xBD 0xA0 0x00
//        H    i    你(3 字节)        \0

'H''i' 各占 1 字节(和 ASCII 一样),'你' 占 3 字节,这就是”变长”:同一个字符串里不同字符占不同字节数。

MSVC 和 UTF-8

MSVC 默认用系统编码(中文 Windows 是 GBK)解释源文件。如果源文件存为 UTF-8,需要在编译时加 /utf-8 选项,否则字符串字面量会按 GBK 解释导致乱码。逆向时遇到字符串编码不对,先确认程序用的什么编码。

宽字符 wchar_t

UTF-16 是 Unicode 的另一种编码方式,固定用 2 字节(或 4 字节代理对)存一个字符。Windows API 有 wchar_t 版本(WCHARLPCWSTR),编码是 UTF-16LE(LE = Little Endian,小端序,低字节存在低地址),也就是 x86 的默认字节序:

wchar_t msg[] = L"Hi";

内存中(UTF-16LE):

地址    值         含义
msg+0   0x48 0x00  'H'(0x0048,低字节 0x48 在前)
msg+2   0x69 0x00  'i'(0x0069,低字节 0x69 在前)
msg+4   0x00 0x00  '\0'(2 字节的 0)

逆向时看到每两个字节一个字符、且第二个字节经常是 0x00,就是 UTF-16 宽字符。

编码英文字符中文字符\0 结尾识别特征
ASCII1 字节不支持1 字节 0x00字节全在 0x00-0x7F
GBK1 字节2 字节1 字节 0x00中文首字节 0x81-0xFE
UTF-81 字节3 字节1 字节 0x00中文首字节 0xE0-0xEF
UTF-162 字节2 字节2 字节 0x00 0x00每隔一字节一个 0x00

逆向时在 x64dbg 内存窗口切到 ASCII 模式,不同编码的字符串显示效果不同。GBK 和 UTF-8 的中文会显示乱码(因为 x64dbg 按 ASCII 解码),但能看到 \0 结尾。

字符串字面量的存储

C 代码里写 "hello" 这样的字符串字面量,有两种用法:

  • char *p = "hello" — p 指向 .rdata 段,字符串只读,改了会崩
  • char arr[] = "hello" — 编译器把 "hello".rdata 复制到栈上,arr 可以修改

两种写法的汇编完全不同。先看 char *p = "hello"

char *p = "hello";
mov  dword ptr [ebp-8], offset ??_C@_05CJBACGMB@hello@  ; p = "hello" 的地址(.rdata 段)

MSVC 把字符串字面量编译成 .rdata 段里的符号,名字是编译器生成的修饰名(如 ??_C@_05CJBACGMB@hello@)。offset 取的是这个符号的地址。p 指向 .rdata 段,不是栈上的数组。如果写成 char arr[] = "hello",编译器会把 "hello".rdata 复制到栈上,运行时逐个 mov 写入。

逆向时看到 offset <符号名> 或指向 .rdata 段地址的指针,就是字符串字面量引用。在 x64dbg 里双击符号或跳到对应地址,能看到 ASCII 字符串。

NULL、\0 和整数 0

C 语言里有三个”零”经常让人混淆:NULL'\0'0。它们在内存里都是全零,区别在于类型和用途

写法类型内存宽度用途
0int4 字节整数零
'\0'char1 字节字符串结尾标记
NULLchar *(指针)4/8 字节空指针(不指向任何地址)

三者值都是 0,但类型不同决定了汇编层面的操作宽度不同:

int  n = 0;        // 4 字节 0
char c = '\0';     // 1 字节 0
int *p = NULL;     // 4 字节 0(32 位程序)
; int n = 0  → 4 字节赋零
mov  dword ptr [ebp-4], 0

; char c = '\0'  → 1 字节赋零
mov  byte ptr [ebp-8], 0

; int *p = NULL  → 4 字节赋零(和 int 0 的汇编完全一样)
mov  dword ptr [ebp-C], 0

NULL 在 C 标准里定义为 ((void *)0),在 32 位程序里就是一个 4 字节的 0。赋值 int *p = NULLint n = 0 的汇编完全相同,区别只在 C 语言的类型系统层面,汇编层面看不出区别。

检查零值时也是按宽度来:

; 检查 char 是否为 '\0'(1 字节)
movsx ecx, byte ptr [eax]
test ecx, ecx

; 检查 int 或指针是否为 0/NULL(4 字节)
mov  eax, dword ptr [ebp-8]
test eax, eax

逆向时看到 test + je/jne 就是检查零值。结合操作宽度(byte ptr 还是 dword ptr)就能判断是在检查字符 \0 还是整数 0 / 空指针 NULL。

NULL 和 0 的区别只在 C 层面

C 标准要求 NULL0 在指针上下文中等价,if (ptr == NULL)if (ptr == 0) 编译出的汇编完全一样。区分 NULL 和 0 是给程序员看的,不是给 CPU 看的。逆向时无法区分”这个 0 是 NULL 还是整数 0”,只能靠上下文(变量是指针还是整数)推断。

字符串操作

字符串比较

int str_equal(const char *a, const char *b) {
    int i = 0;
    while (a[i] != '\0' && a[i] == b[i]) {
        i++;
    }
    return a[i] == b[i];
}

核心汇编:

mov  dword ptr [ebp-8], 0            ; i = 0
check:
mov  eax, dword ptr [ebp+8]          ; eax = a
add  eax, dword ptr [ebp-8]          ; eax = a + i
movsx ecx, byte ptr [eax]            ; ecx = a[i]
test ecx, ecx                        ; a[i] == '\0' ?
je   end
mov  eax, dword ptr [ebp+8]          ; eax = a
add  eax, dword ptr [ebp-8]          ; eax = a + i
movsx ecx, byte ptr [eax]            ; ecx = a[i]
mov  edx, dword ptr [ebp+C]          ; edx = b
add  edx, dword ptr [ebp-8]          ; edx = b + i
movsx eax, byte ptr [edx]            ; eax = b[i]
cmp  ecx, eax                        ; a[i] == b[i] ?
jne  end
mov  eax, dword ptr [ebp-8]          ; i++
add  eax, 1
mov  dword ptr [ebp-8], eax
jmp  check
end:
mov  eax, dword ptr [ebp+8]          ; eax = a
add  eax, dword ptr [ebp-8]          ; eax = a + i
movsx ecx, byte ptr [eax]            ; ecx = a[i]
mov  edx, dword ptr [ebp+C]          ; edx = b
add  edx, dword ptr [ebp-8]          ; edx = b + i
movsx eax, byte ptr [edx]            ; eax = b[i]
cmp  ecx, eax                        ; a[i] == b[i] ? → 决定返回值
jne  false
mov  dword ptr [ebp-D0], 1           ; return 1(相等)
jmp  done
false:
mov  dword ptr [ebp-D0], 0           ; return 0(不等)
done:
mov  eax, dword ptr [ebp-D0]         ; eax = 返回值

识别要点:

  • byte ptr 逐字节访问(char 是 1 字节)
  • movsx 符号扩展到 32 位(MSVC Debug 对 char 默认用 movsx,不是 movzx
  • test reg, reg 检查是否 \0(零值)
  • 两个指针([ebp+8][ebp+C])同时推进
  • end 标签后还有一次 cmp:循环退出时 a[i] != b[i]a[i] == '\0',最终返回值由 a[i] == b[i] 决定
Debug vs Release

本章的 strlenstrcmpstrcpy 汇编都是 Debug 模式的输出,逐字节循环。切到 Release 模式后,编译器会大幅优化:

  • strcpy 的循环可能变成一条 rep movsb(CPU 硬件批量拷贝指令)
  • strlen 可能变成 repne scasb 或更激进的 SIMD 向量化扫描
  • strcmp 可能被内联展开,根本不调用函数

这些指令在汇编基础章讲过。逆向 Release 程序时看到 rep movsb / repne scasb,要想起来它们就是优化后的字符串拷贝/扫描。

字符串长度

int my_strlen(const char *s) {
    int len = 0;
    while (s[len] != '\0') {
        len++;
    }
    return len;
}

核心汇编:

mov  dword ptr [ebp-8], 0           ; len = 0
check:
mov  eax, dword ptr [ebp+8]         ; eax = s
add  eax, dword ptr [ebp-8]         ; eax = s + len
movsx ecx, byte ptr [eax]           ; ecx = s[len](符号扩展到 32 位)
test ecx, ecx                       ; s[len] == '\0' ?
je   end
mov  eax, dword ptr [ebp-8]         ; len++
add  eax, 1
mov  dword ptr [ebp-8], eax
jmp  check
end:
mov  eax, dword ptr [ebp-8]         ; 返回 len

test reg, reg + je 是检查 \0 的标准模式,\0 就是 0,test 检查零值,为零则跳出。

注意这里用的是 movsx(符号扩展)而不是 movzx(零扩展)。对于 \0 检测两者效果一样(0 扩展完还是 0),但 MSVC Debug 模式默认用 movsx,因为 char 在 C 里可以是有符号的。

实战中的 strlen 不是这样的

上面这段逐字节循环只是原理演示。真实的 CRT(C Runtime)实现会一次检查 4 字节甚至更多,用 0x7EFEFEFF 等魔法数字配合位运算快速判断这 4 字节里有没有 \0。逆向时如果看到 0x7EFEFEFF0x01010101 这类常量,那是高度优化的字符串扫描代码,不是逐字节的循环。

字符串拷贝

void my_strcpy(char *dst, const char *src) {
    int i = 0;
    while (src[i] != '\0') {
        dst[i] = src[i];
        i++;
    }
    dst[i] = '\0';
}

核心汇编:

mov  dword ptr [ebp-8], 0           ; i = 0
check:
mov  eax, dword ptr [ebp+C]         ; eax = src
add  eax, dword ptr [ebp-8]         ; eax = src + i
movsx ecx, byte ptr [eax]           ; ecx = src[i]
test ecx, ecx                       ; src[i] == '\0' ?
je   end
mov  eax, dword ptr [ebp+8]         ; eax = dst
add  eax, dword ptr [ebp-8]         ; eax = dst + i
mov  ecx, dword ptr [ebp+C]         ; ecx = src
add  ecx, dword ptr [ebp-8]         ; ecx = src + i
mov  dl, byte ptr [ecx]             ; dl = src[i]
mov  byte ptr [eax], dl             ; dst[i] = src[i]
mov  eax, dword ptr [ebp-8]         ; i++
add  eax, 1
mov  dword ptr [ebp-8], eax
jmp  check
end:
mov  eax, dword ptr [ebp+8]         ; eax = dst
add  eax, dword ptr [ebp-8]         ; eax = dst + i
mov  byte ptr [eax], 0              ; dst[i] = '\0'

strlenstrcmp 相比,strcpy 多了一步写回:读 src[i]movsx 符号扩展到 32 位(因为要 test 检查零值),但拷贝时用 mov dl, byte ptr [ecx] + mov byte ptr [eax], dl 逐字节搬运。循环退出后还要补一个 mov byte ptr [eax], 0 写结尾的 \0

识别要点:两个指针交替读写,一个 byte ptr 读、另一个 byte ptr 写,就是字符串拷贝。

缓冲区溢出

注意上面的汇编里,循环只检查 src[i] == '\0' 决定何时停止,完全不知道 dst 有多大。如果 srcdst 的缓冲区长,写操作会越过 dst 的边界,覆盖相邻的栈变量、返回地址甚至其他函数的数据。这就是经典的缓冲区溢出漏洞,很多 CTF Pwn 题和真实 CVE 的根源。逆向时看到 strcpy 的循环模式,要留意调用方是否检查了长度,有没有用 strncpy 等带长度限制的版本。

逆向识别清单

特征含义
byte ptr 逐字节访问char 数组 / 字符串操作
movsx + byte ptr取 char 元素,符号扩展到 32 位
test reg, reg + je检查 \0(字符串结束)
两个指针同时递增字符串比较
一个 byte ptr 读、一个写字符串拷贝
offset <符号名>.rdata 地址字符串字面量引用
字节在 0x81-0xFEGBK 中文首字节(尾字节可能 < 0x80)
字节在 0xE0-0xEFUTF-8 中文首字节
每隔一字节一个 0x00UTF-16 宽字符

字符串就是 char 数组 + \0 结尾。识别 byte ptr 逐字节 + test 检查零值,就是字符串操作。编码靠字节范围判断(ASCII < 0x80,GBK 中文 0x81-0xFE,UTF-8 中文 0xE0-0xEF)。

练习

  1. 下面这段汇编在做什么?还原出 C 代码。

    mov  dword ptr [ebp-8], 0           ; len = 0
    check:
    mov  eax, dword ptr [ebp+8]         ; eax = s
    add  eax, dword ptr [ebp-8]         ; eax = s + len
    movsx ecx, byte ptr [eax]           ; ecx = s[len]
    test ecx, ecx
    je   done
    mov  eax, dword ptr [ebp-8]         ; len++
    add  eax, 1
    mov  dword ptr [ebp-8], eax
    jmp  check
    done:
    mov  eax, dword ptr [ebp-8]
    参考答案

    计算字符串长度(strlen)。逐字节扫描 s[len],遇到 \0test + je)停止,返回计数 len

    int my_strlen(const char *s) {
        int len = 0;
        while (s[len] != '\0') {
            len++;
        }
        return len;
    }
  2. 在 x64dbg 内存窗口看到以下字节序列,判断编码和内容:

    48 65 6C 6C 6F 00
    参考答案

    ASCII 编码的 "Hello"。6 个字节全在 0x00-0x7F 范围内,末尾 0x00\00x48='H'0x65='e'0x6C='l'0x6C='l'0x6F='o'

  3. 在 x64dbg 内存窗口看到以下字节序列,判断编码:

    C4 E3 BA C3 00
    参考答案

    GBK 编码的 "你好"。首字节 0xC40xBA 都在 0x81-0xFE 范围内,说明是 GBK 中文。0xC4 0xE3 = ‘你’,0xBA 0xC3 = ‘好’,末尾 0x00\0。如果是 UTF-8,中文首字节会在 0xE0-0xEF,且每个中文占 3 字节。

  4. 下面这段汇编访问的是什么类型的字符串?和普通 char 字符串有什么区别?

    mov  eax, dword ptr [ebp+8]         ; eax = ptr
    mov  ax, word ptr [eax]             ; 取 2 字节
    test ax, ax                         ; 检查是否为 0
    je   done
    参考答案

    宽字符(wchar_t / UTF-16)字符串。word ptr 取 2 字节(不是 byte ptr 取 1 字节),test ax, ax 检查 2 字节是否全零(UTF-16 的 \00x0000)。每次递增指针应该加 2(不是 1)。

    int wstrlen(const wchar_t *s) {
        // 每次 word ptr 取 2 字节,步长 2
    }

目录