【问题标题】:Can we read and fault-inject another thread's program counter?我们可以读取和错误注入另一个线程的程序计数器吗?
【发布时间】:2016-10-27 09:17:24
【问题描述】:

假设我们有一个单线程程序,我们希望在发生预定义中断(如定时器中断)时捕获程序计数器 (PC) 的值。

正如您所知,我们只需使用特殊关键字__asm__ 编写特定的汇编代码并在移位 4 个字节后将值弹出堆栈顶部,这似乎很容易。

多线程程序呢?

我们如何从运行在同一进程中的另一个线程获取所有线程的值? (从在多核处理器的单独核心上运行的线程获取值似乎非常不可思议)。 (在多线程程序中,每个线程也有自己的栈和寄存器)。


我想实现一个破坏线程。

为了在目标多线程程序中进行故障注入,故障模型是 SEU(单错误翻转),这意味着程序计数器寄存器中的任意位被随机修改(位翻转)导致违反正确的程序顺序。因此,会发生控制流错误 (CFE)。

由于我们的目标程序是一个多线程程序,我们必须在所有线程的PC上执行故障注入。这是破坏者的任务。它应该能够获得线程的PC来执行错误注入。 假设我们有这段代码,

main ()
{
foo
}

void foo()
{
__asm__{
pop "%eax"
pop "%ebx" // now ebx holds porgram counter value (for main thread)
// her code injection like  00000111 XOR ebx for example
push ...
push ...
};
}

如果我们的程序是一个多线程程序。 这是否意味着我们有不止一个堆栈?

当操作系统执行上下文切换时,这意味着正在运行的线程的堆栈和寄存器移动到内存中的某个位置。这是否意味着如果我们想获取这些线程的程序计数器的值,我们会在内存中找到它们?在哪里?在运行时有可能吗?

【问题讨论】:

  • 中断由您的操作系统处理。您的方法在用户模式下不起作用。
  • 即使你能做到,为什么这会有用?你想解决什么问题?
  • 顺便说一句,线程的状态或执行上下文通常存储在特定于平台的数据结构中
  • 是的,您可以使用操作系统调试服务来执行此操作。毕竟,这就是调试器使用的 :)
  • 你不需要插入断点。您可以通过发送信号随意停止和检查其他线程。

标签: c linux multithreading assembly


【解决方案1】:

当您在标志中使用sigaction()SA_SIGINFO 安装信号处理程序时,信号处理程序获得的第二个参数是指向siginfo_t 的指针,第三个参数是指向ucontext_t 的指针。在 Linux 中,这个结构体包含内核中断线程时寄存器值的集合,包括程序计数器。

#define _POSIX_C_SOURCE 200809L
#define _GNU_SOURCE
#include <signal.h>
#include <ucontext.h>

#if defined(__x86_64__)
#define  PROGCOUNTER(ctx) (((ucontext *)ctx)->uc_mcontext.greg[REG_RIP])
#elif defined(__i386__)
#define  PROGCOUNTER(ctx) (((ucontext *)ctx)->uc_mcontext.greg[REG_EIP])
#else
#error Unsupported architecture.
#endif

void signal_handler(int signum, siginfo_t *info, void *context)
{
    const size_t program_counter = PROGCOUNTER(context);

    /* Do something ... */

}

像往常一样,printf() 等。不是异步信号安全,这意味着在信号处理程序中使用它们是不安全的。如果您希望将程序计数器输出到例如标准错误,你不应该使用任何标准 I/O 来打印到stderr,而是手动构造要打印的字符串,并使用循环到write() 字符串的内容;例如,

#include <stdlib.h>
#include <unistd.h>
#include <errno.h>

static void wrerr(const char *p)
{
    const int   saved_errno = errno;
    const char *q = p;
    ssize_t     n;

    /* Nothing to print? */
    if (!p || !*p)
        return;

    /* Find end of q. strlen() is not async-signal safe. */
    while (*q) q++;

    /* Write data from p to q. */
    while (p < q) {
        n = write(STDERR_FILENO, p, (size_t)(q - p));
        if (n > 0)
            p += n;
        else
        if (n != -1 || errno != EINTR)
            break;
    }

    errno = saved_errno;
}

请注意,您需要在信号处理程序中保持errno 的值不变,这样如果在库函数失败后被中断,被中断的线程仍然会看到正确的errno 值。 (这主要是一个调试问题,并且是“良好的形式”;一些白痴对此嗤之以鼻,因为“它发生的频率不足以让我担心”。)

您的程序可以检查/proc/self/maps 伪文件(它不是真正的文件,而是内核在读取文件时动态生成的东西)以查看程序使用的内存区域,以确定程序是否传递中断时正在运行 C 库函数(非常常见)或其他东西。

如果您想中断多线程程序中的特定线程,只需使用pthread_kill()。否则,信号将被传递给未阻塞信号的线程之一,或多或少是随机的。


这是一个示例程序,在 x86-64 (AMD64) 和 x86 中测试,使用 GCC-4.8.4 使用 -Wall -O2 编译:

#define  _POSIX_C_SOURCE 200809L
#define  _GNU_SOURCE
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <signal.h>
#include <ucontext.h>
#include <time.h>
#include <stdio.h>

#if defined(__x86_64__)
#define PROGRAM_COUNTER(mctx)   ((mctx).gregs[REG_RIP])
#define STACK_POINTER(mctx)     ((mctx).gregs[REG_RSP])
#elif defined(__i386__)
#define PROGRAM_COUNTER(mctx)   ((mctx).gregs[REG_EIP])
#define STACK_POINTER(mctx)     ((mctx).gregs[REG_ESP])
#else
#error Unsupported hardware architecture.
#endif

#define MAX_SIGNALS  64
#define MCTX(ctx)    (((ucontext_t *)ctx)->uc_mcontext)

static void wrerr(const char *p, const char *q)
{
    while (p < q) {
        ssize_t n = write(STDERR_FILENO, p, (size_t)(q - p));
        if (n > 0)
            p += n;
        else
        if (n != -1 || errno != EINTR)
            break;
    }
}

static const char hexc[16] = "0123456789abcdef";

static inline char *prehex(char *before, size_t value)
{
    do {
        *(--before) = hexc[value & 15];
        value /= (size_t)16;
    } while (value);
    *(--before) = 'x';
    *(--before) = '0';
    return before;
}

static volatile sig_atomic_t done = 0;

static void handle_done(int signum)
{
    done = signum;
}

static int install_done(const int signum)
{
    struct sigaction act;

    memset(&act, 0, sizeof act);
    sigemptyset(&act.sa_mask);
    act.sa_handler = handle_done;
    act.sa_flags = 0;
    if (sigaction(signum, &act, NULL) == -1)
        return errno;

    return 0;
}

static size_t jump_target[MAX_SIGNALS] = { 0 };
static size_t jump_stack[MAX_SIGNALS] = { 0 };

static void handle_jump(int signum, siginfo_t *info, void *context)
{
    const int   saved_errno = errno;
    char        buffer[128];
    char       *p = buffer + sizeof buffer;

    *(--p) = '\n';
    p = prehex(p, STACK_POINTER(MCTX(context)));
    *(--p) = ' ';
    *(--p) = 'k';
    *(--p) = 'c';
    *(--p) = 'a';
    *(--p) = 't';
    *(--p) = 's';
    *(--p) = ' ';
    *(--p) = ',';
    p = prehex(p, PROGRAM_COUNTER(MCTX(context)));
    *(--p) = ' ';
    *(--p) = '@';
    wrerr(p, buffer + sizeof buffer);

    if (signum >= 0 && signum < MAX_SIGNALS) {
        if (jump_target[signum])
            PROGRAM_COUNTER(MCTX(context)) = jump_target[signum];
        if (jump_stack[signum])
            STACK_POINTER(MCTX(context)) = jump_stack[signum];
    }

    errno = saved_errno;
}

static int install_jump(const int signum, void *target, size_t stack)
{
    struct sigaction act;

    if (signum < 0 || signum >= MAX_SIGNALS)
        return errno = EINVAL;

    jump_target[signum] = (size_t)target;
    jump_stack[signum] = (size_t)stack;

    memset(&act, 0, sizeof act);
    sigemptyset(&act.sa_mask);
    act.sa_sigaction = handle_jump;
    act.sa_flags = SA_SIGINFO;
    if (sigaction(signum, &act, NULL) == -1)
        return errno;

    return 0;
}

int main(int argc, char *argv[])
{
    const struct timespec sec = { .tv_sec = 1, .tv_nsec = 0L };
    const int pid = (int)getpid();
    ucontext_t ctx;

    printf("Run\n");
    printf("\tkill -KILL %d\n", pid);
    printf("\tkill -TERM %d\n", pid);
    printf("\tkill -HUP  %d\n", pid);
    printf("\tkill -INT  %d\n", pid);
    printf("or press Ctrl+C to stop this process, or\n");
    printf("\tkill -USR1 %d\n", pid);
    printf("\tkill -USR2 %d\n", pid);
    printf("to send the respective signal to this process.\n");
    fflush(stdout);

    if (install_done(SIGTERM) ||
        install_done(SIGHUP)  ||
        install_done(SIGINT) ) {
        printf("Cannot install signal handlers: %s.\n", strerror(errno));
        return EXIT_FAILURE;
    }

    getcontext(&ctx);

    if (install_jump(SIGUSR1, &&usr1_target, STACK_POINTER(MCTX(&ctx))) ||
        install_jump(SIGUSR2, &&usr2_target, STACK_POINTER(MCTX(&ctx))) ) {
        printf("Cannot install signal handlers: %s.\n", strerror(errno));
        return EXIT_FAILURE;
    }

    /* These are expressions that should evaluate to false, but the compiler
     * should not be able to optimize them away. */
    if (argv[0][1] == 'A') {
usr1_target:
        fputs("USR1\n", stdout);
        fflush(stdout);
    }

    if (argv[0][1] == 'B') {
usr2_target:
        fputs("USR2\n", stdout);
        fflush(stdout);
    }

    while (!done) {
        putchar('.');
        fflush(stdout);
        nanosleep(&sec, NULL);
    }

    fputs("\nAll done.\n", stdout);
    fflush(stdout);

    return EXIT_SUCCESS;
}

如果将上面的内容保存为example.c,则可以使用编译

gcc -Wall -O2 example.c -o example

并运行它

./example

Ctrl+C 退出程序。复制命令(用于发送SIGUSR1SIGUSR2 信号),然后从另一个窗口运行它们,您会看到它们修改了当前执行的位置。 (这些信号导致程序计数器/指令指针跳回,进入一个不应该执行的 if 子句。)

有两组信号处理程序。 handle_done() 只是设置 done 标志。 handle_jump() 向标准错误输出消息(使用低级 I/O),如果指定,则更新程序计数器(指令指针)和堆栈指针。

在创建这样的示例程序时,堆栈指针是棘手的部分。如果我们只满足于让程序崩溃,那将是简单。但是,示例只有在有效时才有用。

当我们任意更改程序计数器/指令指针,并且在函数调用(大多数 C 库函数...)中传递中断时,返回地址留在堆栈上。内核可以在任何时候传递中断,所以我们甚至不能假设中断是在函数调用中传递的!因此,为了确保测试程序不会崩溃,我不得不更新程序计数器/指令指针和堆栈指针。

当接收到跳转信号时,堆栈指针被重置为我使用getcontext() 获得的值。这不保证适用于任何跳跃位置;对于一个最小的例子,这只是我能做的最好的事情。我肯定假设跳转标签就在附近,而不是在编译器可能会弄乱堆栈的子范围内,请注意。

同样重要的是要记住,因为我们正在处理留给 C 编译器的细节,所以我们必须符合编译器生成的任何二进制代码,而不是相反。对于进程及其线程的可靠操作,ptrace() 是一个更好(老实说,更简单)的接口。您只需设置一个父进程,并在目标跟踪的子进程中,明确允许跟踪。我已经向examples herehere 展示了如何在目标进程中启动、停止和单步执行各个线程。最难的部分是理解整体方案、概念;代码本身比这种信号处理程序上下文操作方式更容易,而且更健壮。

对于自我引入的寄存器错误(程序计数器/指令指针或任何其他寄存器),假设大多数时间导致进程崩溃,此信号处理程序上下文操作应该足够了。

【讨论】:

  • 你能修改uc_mcontext,让信号处理程序返回到不同的地方吗?如果没有,这并不能真正解决 OP 将故障注入另一个线程的问题。不过,巧妙的把戏;我不知道线程上下文是这样暴露给信号处理程序的。
  • @PeterCordes:是的,你可以。信号处理程序返回后,寄存器状态将反映uc_mcontext。特别是,x86-64 上的uc_mcontext.gregs[REG_RIP] 将更改代码继续执行的位置。请注意,GCC 提供了有用的运算符&amp;&amp; 作为扩展;我通过将标签的地址 (&amp;&amp;labelname) 分配给静态变量来验证这一点,然后修改 RIP/EIP 寄存器以反映这一点,实际上,代码确实会继续在标签处执行。
  • 酷!这意味着 OP 可以只为 SIGUSR1 添加一个信号处理程序或处理uc_mcontext.gregs[REG_RIP] 的低位的东西,并让线程自己进行故障注入以响应信号。这可能比ptrace 更容易(尤其是因为您已经提供了代码:)
  • @PeterCordes:没错。我个人定义了一个宏,x86-64 上的#define PROGRAM_COUNTER(ctx) (((ucontext_t *)ctx)-&gt;uc_mcontext.gregs[REG_RIP]) 和x86 上的#define PROGRAM_COUNTER(ctx) (((ucontext_t *)ctx)-&gt;uc_mcontext.gregs[REG_EIP])(没有其他拱门要测试,我懒得去看内核源代码)来访问程序计数器/指令指针,使用信号处理程序可以访问的void *context 参数。我将在上面的答案中添加一些概念验证代码。 (我使用的测试代码太丑了,不能在公共场合使用;我会羞愧而死。:)
  • @NominalAnimal, PeterCordes 这太棒了,非常感谢!我正在尝试执行和实施建议的解决方案。非常感谢您与我的合作。
【解决方案2】:

不,当线程正在执行时这是不可能的。当一个线程正在执行时,其程序计数器 (EIP) 的当前值对于它正在运行的 CPU 内核是私有的。它在任何地方的内存中都不可用。

架构可以有特殊指令来发送处理器间请求并查询执行状态,但 x86 没有。


但是,您可以使用ptrace system calls 来做任何调试器可以做的事情;中断另一个线程并修改它的任何状态(通用寄存器、标志、程序计数器等)我不能给你一个例子,我只知道这是调试器用来修改另一个线程保存状态的系统调用线程/进程。例如,this question 询问是否使用 ptrace 修改另一个进程的 RIP(用于测试代码注入)。

我不确定在同一进程中从另一个线程跟踪一个线程是否可行;您的故障注入器作为一个单独的进程可能会更好地工作,它会干扰另一个进程的线程。

无论如何,当您进行ptrace 系统调用以修改另一个线程中的某些内容时,运行您的系统调用的 CPU 将向运行另一个线程的 CPU 上的内核发送处理器间消息,这会打断你想弄乱的那个线程。 Its state will be saved into memory by the kernel,任何CPU都可以修改。

一旦其他线程停止运行,它就不再与任何 CPU 紧密关联。在已经有热缓存的 CPU 上恢复它会更便宜,但这不能保证,因为一旦 CPU 不再忙于运行您导致停止的线程,它就可以开始运行任何其他线程。


旁注,与线程间错误注入无关:

你修改 EIP (foo()) 的 C 函数真的很丑,顺便说一句:

首先,它是 MSVC 内联汇编,所以没有 Linux 编译器会接受它(也许是 icc?)。其次,它只适用于-fno-omit-frame-pointer,因为它假定它在一个被推送%ebp 的函数内部。

在 asm 中编写整个函数会容易得多。在 64 位非内联汇编中,您只需编写:

global  fault_inject_program_counter
fault_inject_program_counter:
    xor   qword [rsp],  0b00000111
    ret

并使用 NASM 或 YASM 单独组装该文件,并将 .o 与调用它的代码链接。 (我假设您更喜欢 Intel 语法,因为您使用了 MSVC-style asm {} instead GNU C asm("pop ; ... ; "::: ); inline asm.


内联 asm 版本可能如下所示:

// this can't possibly work if inlined, or if compiled without `-fno-omit-frame-pointer
__attribute__((noinline)) void foo()
{
    __asm__ volatile(
    // "pop %eax\n\t"
    // "pop %ebx\n\t"    // now ebx holds the return address
    // here code injection like  00000111 XOR ebx for example

    // normal people would just write
       "xorl  $0b00000111,  -4(%esp)\n\t"
    // to modify the return value in-place, in a function with a frame pointer.

    // push ...
    // push ...
    );
}

【讨论】:

    猜你喜欢
    • 2015-07-17
    • 1970-01-01
    • 2012-02-25
    • 1970-01-01
    • 2021-11-20
    • 2020-05-17
    • 2021-10-29
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多