一、内存越界
内存的问题是c/c++的传统的、经典的问题。从不同的维度和不同的层次都可以看到不一样的情况。对于一些不太熟悉调试的开发者来说,堆和栈的崩溃在他们眼中都是一样的,反正结果都是崩溃的。但在实际的应用中,有经验的开发者可以迅速的根据情况判别是堆还是栈引起的越界崩溃。
正如前面反复提到的,解决问题的关键不是一眼发现问题,而是学会逐步抽丝剥茧的能力,一步步的定位问题的策略。有了解决问题的思路和解决的方式,就可以适用到所有的问题处理上,而不只是学会解决某个问题或某类问题。
c++中内存的问题往往有一定的滞后性,这个滞后性和堆和栈的问题引起有着密切的关系。一般来说,栈的内存越界引起的崩溃往往很容易快速定位;但堆引起的内存越界,相对要复杂一些,个别情况下会相当的复杂。如果再使用了模板或STL中的组件,有可能满屏的错误让开发者当场宕机。
二、栈和堆的不同
栈由系统管理而堆由开发者自己管理。既然堆和栈的越界导致问题的结果排查有所不同,下面就对他们进行一下详细的结比分析:
- 越界读
heap overflow和stack overflow如果是在内存越界读的情况下,还是有所不同的。正常的情况下,堆越界的读,只要不偏离特别离谱,比如直接读到保护的地址空间,一般是不会引起崩溃的。但栈越界读,特别是开启了栈保护的情况下(类似g++中的-fstack-protector),程序会调用__stack_chk_fail结束执行。此时的崩溃情况指向明确就在破坏的栈地址。但如果没有启用栈保护的情况下,有可能不会崩溃,但也有可能会在调用结束或程序结束时引发崩溃的情况 - 越界写
如果是越界写的情况,相对复杂一些。对于栈空间来说,如果越界范围不大,则可能表现为数据的异常,但不一定会立刻崩溃。不过如果不小心这段写正好覆盖了函数返回时的地址,则会出现SIGSEGV。此时的崩溃的地址会在返回地址的附近。
对于堆来说,越界写可能破坏附近相邻的其它数据,如果这些数据正在被保护或者被破坏后又被使用,就有可能产生崩溃。但这种崩溃往往不会立刻发生。而在在重新分配、释放或其它情况下才可能崩溃(如函数返回等)。它报的错误一般是"invalid next size、invalid pointer、corrupted top size、double free or corruption"。尤其最后一个重复释放是最容易出现的问题。
另外在c++中还有一个比较特殊的情况就是虚表或函数指针的问题,如果越界写对它们造成了破坏,在其后应用时,会SIGSEGV崩溃 - 多线程情况下
在复杂的多线程的情况下,由于存在着guard page,栈的越界则可能直接SIGSEGV,其崩溃时的显示的地址空间可能在离具体地址不远的位置 - 过深调用时崩溃
栈空间崩溃的另外一个重要点在于过深的调用,典型的就是递归。SIGSEGV的位置大约在栈的边界,如果使用bt栈回溯的情况下可以看到大量的重复调用 - 内存映射的处理
对于内存映射来说,因为内存分配的过大,一般可能也有guard page,这时越界写,也会直接SIGSEGV。更准确的说是内存映射越界访问未映射页面或PROT_NONE页面时,会立刻发生SIGSEGV
通过上面的分析,其实可以发现,栈由于自身是系统管理,空间有限,所以不管时越界写还是读,其影响基本都是局部的、可以迅速的引起崩溃并且崩溃点的位置往往在函数调用的当前位置或返回位置;而堆由于自行管理,空间范围较大,调用关系相对也复杂,所以其影响范围也大、出现崩溃的现象会延迟并且崩溃点往往不是引发崩溃的位置。
三、应对机制
要想进行崩溃的分析,一个最简单的方法就是直接使用gdb处理Core文件。处理的机制一般如下:
-
设置Core dump
一般默认的情况下,core dump是禁止的。可以使用下面的命令打开:ulimit -c unlimited #开启Core文件支持 g++ -g -O0 -fno-omit-frame-pointer demo.cpp #-g一般默认-fno-omit-frame-pointer开启,但建议仍然增加 -
gdb调试Core文件
在程序崩溃后,找到相关的core文件,一般是core后面加一大串数字。然后使用下面的命令进行处理:gdb ./runname core bt full -
定位问题
然后就可以使用各种gdb的相关命令进行分析了,比如:info reg x/20i $pc #从当前程序计数器指向的地址开始,反汇编并显示接下来的20条机器指令 p $_siginfo._sifields._sigfault.si_addr #打印导致本次信号(通常是SIGSEGV)的非法内存访问地址
一般来说,通过上面的分析,基础的崩溃问题都可以较为快捷的定位到原因。复杂的情况就需要根据个人的经验或借助一些工具来进行分析了。至于更复杂和难于发现的问题,就需要认真仔细的对程序反复的分析和调试了。
四、实例分析
下面给出一个实例,先看代码:
c++
#include <sys/mman.h>
#include <unistd.h>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <string_view>
#include <thread>
#if defined(__GNUC__) || defined(__clang__)
#define NOINLINE __attribute__((noinline))
#else
#define NOINLINE
#endif
static volatile std::uint64_t gSink = 0;
static NOINLINE void stackOob() {
volatile int v[8]{};
v[8] = 0x1234;
gSink = static_cast<std::uint64_t>(v[0]);
}
static NOINLINE void heapOob() {
int *v = new int[8]{};
v[8] = 0x1234;
gSink = static_cast<std::uint64_t>(v[0]);
delete[] v;
}
static NOINLINE void recurse(std::size_t depth) {
volatile unsigned char pad[4096];
pad[depth % sizeof(pad)] = static_cast<unsigned char>(depth);
recurse(depth + 1);
gSink += pad[0]; // prevent tail-call elimination
}
static NOINLINE int *returnLocalAddress() {
int local = 56;
int *result = &local;
#if defined(__GNUC__) || defined(__clang__)
asm volatile("" : "+r"(result) : : "memory");
#endif
return result; // dangling immediately after return
}
static NOINLINE void returnedPointer() {
int *p = returnLocalAddress();
*p = 7;
gSink = static_cast<std::uint64_t>(*p);
}
static NOINLINE void threadCrash() {
std::thread worker([] {
volatile int *p = nullptr;
*p = 1;
});
worker.join();
}
static NOINLINE void opaqueFree(void *p) {
#if defined(__GNUC__) || defined(__clang__)
asm volatile("" : "+r"(p) : : "memory");
#endif
std::free(p);
}
static NOINLINE void doubleFree() {
void *p = std::malloc(128);
if (p == nullptr) {
std::abort();
}
opaqueFree(p);
opaqueFree(p);
}
static NOINLINE void invalidFree() {
char *base = static_cast<char *>(std::malloc(128));
if (base == nullptr) {
std::abort();
}
void *interior = base + 1;
#if defined(__GNUC__) || defined(__clang__)
asm volatile("" : "+r"(interior) : : "memory");
#endif
std::free(interior); // not the pointer returned by malloc
}
static NOINLINE void invalidPointer() {
volatile std::uintptr_t raw = 0x1;
auto *p = reinterpret_cast<volatile int *>(raw);
*p = 9;
}
static NOINLINE void corruptedDll() {
#if defined(__GLIBC__)
constexpr std::size_t n = 0x500; // bypass tcache/small fast paths
char *victim = static_cast<char *>(std::malloc(n));
void *guard = std::malloc(0x40); // prevent top-chunk coalescing
if (victim == nullptr || guard == nullptr) {
std::abort();
}
std::memset(victim, 0x41, n);
std::free(victim);
void **links = reinterpret_cast<void **>(victim);
links[1] = victim - 2 * sizeof(std::size_t);
void *trigger = std::malloc(n + 0x100);
gSink = reinterpret_cast<std::uintptr_t>(trigger);
std::free(guard);
#else
std::cerr << "corruptedDll requires glibc" << std::endl;
std::exit(2);
#endif
}
static NOINLINE void mmapOob() {
const long rawPageSize = ::sysconf(_SC_PAGESIZE);
if (rawPageSize <= 0) {
std::abort();
}
const std::size_t pageSize = static_cast<std::size_t>(rawPageSize);
void *area = ::mmap(nullptr, pageSize * 2, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (area == MAP_FAILED) {
std::perror("mmap");
std::exit(2);
}
if (::mprotect(area, pageSize, PROT_READ | PROT_WRITE) != 0) {
std::perror("mprotect");
std::exit(2);
}
auto *bytes = static_cast<volatile unsigned char *>(area);
bytes[pageSize - 1] = 0x11;
bytes[pageSize] = 0x22; // first byte in the PROT_NONE guard page
}
struct Case {
std::string_view name;
void (*run)();
};
static constexpr Case kCases[] = {
{"stackOob", stackOob}, {"heapOob", heapOob}, {"recursion", [] { recurse(0); }}, {"returnedPointer", returnedPointer},
{"threadCrash", threadCrash}, {"doubleFree", doubleFree}, {"invalidFree", invalidFree}, {"invalidPointer", invalidPointer},
{"corruptedDll", corruptedDll}, {"mmapOob", mmapOob},
};
int main(int argc, char **argv) {
if (argc != 2) {
std::cerr << "usage: " << argv[0] << " <case>" << std::endl;
for (const auto &item : kCases) {
std::cerr << " " << item.name << std::endl;
}
return 2;
}
for (const auto &item : kCases) {
if (item.name == argv[1]) {
std::cerr << "running: " << item.name << std::endl;
item.run();
std::cerr << "returned without an immediate crash; use ASan/Valgrind" << std::endl;
return 0;
}
}
std::cerr << "unknown case: " << argv[1] << std::endl;
return 2;
}
说明:opaqueFree和invalidFree等函数中的汇编代码,主要是用来对编译器进行阻止性处理,防止直接编译报各种错误
编译方式:
g++ -std=c++17 -g3 -Og -fno-omit-frame-pointer -fno-optimize-sibling-calls -pthread main.cpp -o crash_demo
会生成相关的core文件,注意,不同的系统和不同的版本的操作系统,可能生成的位置有所不同,大家可根据自己的情况在当前目录或相关目录下查找
使用GDB,其常用的命令说明:
set pagination off # 关闭分页
info threads # 看有哪些线程
thread apply all bt # 看所有线程调用栈
thread apply all bt full # 需要时看详细局部变量
frame 0 # 切到崩溃帧
list # 查看崩溃点源码
info locals # 查看局部变量
info registers # 查看寄存器
启动GDB调试:
gdb crash_demo core.1111
...忽略说明
Reading symbols from crash_demo...
[New LWP 2681680]
[New LWP 2681679]
This GDB supports auto-downloading debuginfo from the following URLs:
<https://debuginfod.ubuntu.com>
Enable debuginfod for this session? (y or [n]) y
Debuginfod has been enabled.
To make this setting permanent, add 'set debuginfod enabled on' to .gdbinit.
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/x86_64-linux-gnu/libthread_db.so.1".
Core was generated by `./crash_demo threadCrash'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0 operator() (__closure=<optimized out>) at main.cpp:58
58 *p = 1;
[Current thread is 1 (Thread 0x7605dc9ff6c0 (LWP 2681680))]
(gdb) info thread
Id Target Id Frame
* 1 Thread 0x7605dc9ff6c0 (LWP 2681680) operator() (__closure=<optimized out>) at main.cpp:58
2 Thread 0x7605dd180780 (LWP 2681679) __syscall_cancel_arch () at ../sysdeps/unix/sysv/linux/x86_64/syscall_cancel.S:56
thread apply all bt
Thread 2 (Thread 0x7605dd180780 (LWP 2681679)):
#0 __syscall_cancel_arch () at ../sysdeps/unix/sysv/linux/x86_64/syscall_cancel.S:56
#1 0x00007605dcaa05ec in __internal_syscall_cancel (a1=a1@entry=129767548386536, a2=<optimized out>, a3=<optimized out>, a4=a4@entry=0, a5=a5@entry=0, a6=a6@entry=4294967295, nr=202) at ./nptl/cancellation.c:49
#2 0x00007605dcaa09c7 in __futex_abstimed_wait_common64 (private=128, futex_word=0x7605dc9ffce8, expected=<optimized out>, op=265, abstime=0x0, cancel=true) at ./nptl/futex-internal.c:57
#3 __futex_abstimed_wait_common (futex_word=0x7605dc9ffce8, expected=<optimized out>, clockid=0, abstime=0x0, private=128, cancel=true) at ./nptl/futex-internal.c:87
#4 __GI___futex_abstimed_wait_cancelable64 (futex_word=futex_word@entry=0x7605dc9ffce8, expected=<optimized out>, clockid=clockid@entry=0, abstime=abstime@entry=0x0, private=private@entry=128) at ./nptl/futex-internal.c:139
#5 0x00007605dcaa60b9 in __pthread_clockjoin_ex (threadid=129767548384960, thread_return=0x0, clockid=0, abstime=0x0, cancel=<optimized out>) at ./nptl/pthread_join_common.c:66
#6 0x00007605dcef5f73 in std::thread::join() () from /usr/lib/x86_64-linux-gnu/libstdc++.so.6
#7 0x00006241673de8b2 in threadCrash () at main.cpp:60
#8 0x00006241673dea0e in main (argc=2, argv=<optimized out>) at main.cpp:166
Thread 1 (Thread 0x7605dc9ff6c0 (LWP 2681680)):
#0 operator() (__closure=<optimized out>) at main.cpp:58
#1 std::__invoke_impl<void, threadCrash()::<lambda()> > (__f=...) at /usr/include/c++/15/bits/invoke.h:63
......
(gdb) thread apply all bt full
Thread 2 (Thread 0x7605dd180780 (LWP 2681679)):
#0 __syscall_cancel_arch () at ../sysdeps/unix/sysv/linux/x86_64/syscall_cancel.S:56
No locals.
#1 0x00007605dcaa05ec in __internal_syscall_cancel (a1=a1@entry=129767548386536, a2=<optimized out>, a3=<optimized out>, a4=a4@entry=0, a5=a5@entry=0, a6=a6@entry=4294967295, nr=202) at ./nptl/cancellation.c:49
result = <optimized out>
pd = <optimized out>
ch = <optimized out>
#2 0x00007605dcaa09c7 in __futex_abstimed_wait_common64 (private=128, futex_word=0x7605dc9ffce8, expected=<optimized out>, op=265, abstime=0x0, cancel=true) at ./nptl/futex-internal.c:57
resultvar = <optimized out>
__arg6 = <optimized out>
.....
(gdb) frame 0
#0 operator() (__closure=<optimized out>) at main.cpp:58
58 *p = 1;
(gdb) list
53 }
54
55 static NOINLINE void threadCrash() {
56 std::thread worker([] {
57 volatile int *p = nullptr;
58 *p = 1;
59 });
60 worker.join();
61 }
62
(gdb) info locals
p = 0x0
(gdb) info reg
rax 0x6241673e0b90 108033044515728
rbx 0x62417240b020 108033229238304
rcx 0x7605dcaa3f3d 129767549058877
rdx 0x0 0
rsi 0x7605dc9fffb0 129767548387248
rdi 0x62417240b020 108033229238304
(gdb) x/20i $pc
=> 0x6241673de56a <std::thread::_State_impl<std::thread::_Invoker<std::tuple<threadCrash()::<lambda()> > > >::_M_run(void)+4>: movl $0x1,0x0
0x6241673de575 <std::thread::_State_impl<std::thread::_Invoker<std::tuple<threadCrash()::<lambda()> > > >::_M_run(void)+15>: ret
0x6241673de576 <mmapOob()>: endbr64
0x6241673de57a <mmapOob()+4>: push %rbp
0x6241673de57b <mmapOob()+5>: mov %rsp,%rbp
0x6241673de57e <mmapOob()+8>: push %r12
0x6241673de580 <mmapOob()+10>: push %rbx
0x6241673de581 <mmapOob()+11>: mov $0x1e,%edi
0x6241673de586 <mmapOob()+16>: call 0x6241673de220 <sysconf@plt>
0x6241673de58b <mmapOob()+21>: test %rax,%rax
0x6241673de58e <mmapOob()+24>: jle 0x6241673de5e7 <mmapOob()+113>
0x6241673de590 <mmapOob()+26>: mov %rax,%r12
0x6241673de593 <mmapOob()+29>: lea (%rax,%rax,1),%rsi
0x6241673de597 <mmapOob()+33>: mov $0x0,%r9d
0x6241673de59d <mmapOob()+39>: mov $0xffffffff,%r8d
0x6241673de5a3 <mmapOob()+45>: mov $0x22,%ecx
0x6241673de5a8 <mmapOob()+50>: mov $0x0,%edx
0x6241673de5ad <mmapOob()+55>: mov $0x0,%edi
0x6241673de5b2 <mmapOob()+60>: call 0x6241673de310 <mmap@plt>
0x6241673de5b7 <mmapOob()+65>: mov %rax,%rbx
(gdb) p $_siginfo._sifields._sigfault.si_addr
$1 = (void *) 0x0
(gdb)
线程命令对应着上面的两人个线程ID,带星号表示正在此线程中。这个线程崩溃的代码太简单所以直接在GDB中可以看出来,而不必分析各种变量和内存。但方法给出了,再包括前面用AI分析辅助的方式,其实就很容易定位相关的问题所在位置了。
也可以使用工具:
valgrind --tool=memcheck --track-origins=yes --leak-check=full --show-leak-kinds=all --keep-stacktraces=alloc-and-free --error-exitcode=99 ./crash_demo heapOob
==2678521== Memcheck, a memory error detector
==2678521== Copyright (C) 2002-2024, and GNU GPL'd, by Julian Seward et al.
==2678521== Using Valgrind-3.26.0 and LibVEX; rerun with -h for copyright info
==2678521== Command: ./crash_demo heapOob
==2678521==
running: heapOob
==2678521== Invalid write of size 4
==2678521== at 0x40027B3: heapOob() (main.cpp:28)
==2678521== by 0x4002A0D: main (main.cpp:166)
==2678521== Address 0x4eab0a0 is 0 bytes after a block of size 32 alloc'd
==2678521== at 0x48535F3: operator new[](unsigned long) (vg_replace_malloc.c:730)
==2678521== by 0x4002779: heapOob() (main.cpp:27)
==2678521== by 0x4002A0D: main (main.cpp:166)
==2678521==
returned without an immediate crash; use ASan/Valgrind
==2678521==
==2678521== HEAP SUMMARY:
==2678521== in use at exit: 0 bytes in 0 blocks
==2678521== total heap usage: 2 allocs, 2 frees, 73,760 bytes allocated
==2678521==
==2678521== All heap blocks were freed -- no leaks are possible
==2678521==
==2678521== For lists of detected and suppressed errors, rerun with: -s
==2678521== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
通过上面分析可以看出,给出了"2678521 Invalid write of size 4",写入了四个字节。位置在main.cpp的第28行的heapOob函数中。越界的地址是"2678521 Address 0x4eab0a0 is 0 bytes after a block of size 32 alloc'd"。程序还打印了"returned without an immediate crash; use ASan/Valgrind"。这是因为小范围越界glibc的堆chunk有对齐和填充,实际占用的空间可能更大,而这四个字节可能在这其中,所以没有破坏到chunk或下一个的指针。故而程序得以正常终止。很清晰的说明。
如果是使用ASan或UBSan分析,则必须使用下面的编译方式重新编译:
g++ -std=c++17 \
-g3 -O1 \
-fno-omit-frame-pointer \
-fno-optimize-sibling-calls \
-fsanitize=address,undefined \
-pthread \
main.cpp \
-o crash_demo_asan
运行命令:
ASAN_OPTIONS=detect_stack_use_after_return=1:abort_on_error=1:strict_string_checks=1 ./crash_demo_asan doubleFree
running: doubleFree
=================================================================
==2679167==ERROR: AddressSanitizer: attempting double-free on 0x77f185be0040 in thread T0:
#0 0x7b318792a3ff in free ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:51
#1 0x60b23926fc6d in opaqueFree /home/qt65_project/crashDumpTest/main.cpp:67
#2 0x60b23926fc9e in doubleFree /home/qt65_project/crashDumpTest/main.cpp:76
#3 0x60b239270fca in main /home/qt65_project/crashDumpTest/main.cpp:166
#4 0x7b318682a600 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:59
#5 0x7b318682a717 in __libc_start_main_impl ../csu/libc-start.c:360
#6 0x60b23926f664 in _start (/home/qt65_project/crashDumpTest/crash_demo_asan+0x6664) (BuildId: 4801c09f88806c865e61592e1cb0dbe35b9397c3)
0x77f185be0040 is located 0 bytes inside of 128-byte region [0x77f185be0040,0x77f185be00c0)
freed by thread T0 here:
#0 0x7b318792a3ff in free ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:51
#1 0x60b23926fc6d in opaqueFree /home/qt65_project/crashDumpTest/main.cpp:67
#2 0x60b23926fc96 in doubleFree /home/qt65_project/crashDumpTest/main.cpp:75
#3 0x60b239270fca in main /home/qt65_project/crashDumpTest/main.cpp:166
#4 0x7b318682a600 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:59
#5 0x7b318682a717 in __libc_start_main_impl ../csu/libc-start.c:360
#6 0x60b23926f664 in _start (/home/qt65_project/crashDumpTest/crash_demo_asan+0x6664) (BuildId: 4801c09f88806c865e61592e1cb0dbe35b9397c3)
previously allocated by thread T0 here:
#0 0x7b318792b60f in malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:67
#1 0x60b23926fc86 in doubleFree /home/qt65_project/crashDumpTest/main.cpp:71
#2 0x60b239270fca in main /home/qt65_project/crashDumpTest/main.cpp:166
#3 0x7b318682a600 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:59
#4 0x7b318682a717 in __libc_start_main_impl ../csu/libc-start.c:360
#5 0x60b23926f664 in _start (/home/qt65_project/crashDumpTest/crash_demo_asan+0x6664) (BuildId: 4801c09f88806c865e61592e1cb0dbe35b9397c3)
SUMMARY: AddressSanitizer: double-free /home/qt65_project/crashDumpTest/main.cpp:67 in opaqueFree
==2679167==ABORTING
已中止 ASAN_OPTIONS=detect_stack_use_after_return=1:abort_on_error=1:strict_string_checks=1 ./crash_demo_asan doubleFree
运行结果的开始就告诉这是一个二次释放的问题,问题的位置在opaqueFree函数即main.cpp的67行。第二次释放在76行,也是调用这个函数。而第一次释放则在doubleFree函数的第75行。上面的提示非常清晰。
更多的工具如TSan(性能开销大,内存需要高,一般推荐只在开发和测试环境中使用)则等请自行查阅相关资料处理。
五、总结
内存问题虽然是一个大问题,但在实践中已经积聚了很多年的应对经验,工具也十分丰富。这样,在解决内存的堆栈溢出越界的问题时,可以采纳的方法有很多。而且不同的平台可能也有不同的工具和机制,这就需要开发者因地制宜的选择和使用恰当的工具,就能够较为快速的定位和解决内存的问题。