首页
社区
课程
招聘
[翻译]使用Rust进行系统编程 - take2
发表于: 2023-3-21 15:48 6348

[翻译]使用Rust进行系统编程 - take2

2023-3-21 15:48
6348

我有个朋友曾经说过:知不足者好学,耻下问者自满;这便是问题所在,我对自己所做的工作既感到自满又觉得不足。
自从完成上一篇Rust文章后,我就经常被问到关于Rust编程的问题,问题本身不难,但是为了回答这些问题,我每次都不得不重新阅读我的文章——我对我上一篇文章中文版)并不满意,代码质量和结构都不是很好;所以,我决定尽可能的重写它,并且也趁此机会检验一下两年后我是否需要再次重写 :p

代码结构

在开始之前,我想解释一下我对我文章中的代码片段的处理方法。大部分人在搜索文章时,希望文章中提供的代码能直接在自己本地机器上运行;遗憾的是,在我的文章中,这种想法行不通,这是因为我提供的是代码片段而非一个包含所有函数、导入和结构的程序。
我的方法有点儿不同,代码片段只是为了说明我在某一节中所解释的概念,这些概念不是一个完整的程序;然而,我依旧会将完整的代码放在文章末尾的链接里,这样你就可以自己尝试运行代码了。

运行程序

我们依旧从我两年前写的那段差强人意的代码开始——运行另一个程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
fn main() {
    // breakpoints to set
    let breakpoints: [u64; 1] = [0x8048451];
 
    match unsafe{fork()} {
 
        Ok(ForkResult::Child) => {
            run_child();
        }
 
        Ok(ForkResult::Parent {child}) => {
            run_parent(child, &breakpoints);
        }
 
        Err(err) => {
            panic!("[main] fork() failed: {}", err);
        }
    };
}
 
// Code that runs only for child
fn run_child() {
    // Allows process to be traced
    ptrace::traceme().unwrap();
 
    // Disable ASLR for this process
    personality(linux_personality::ADDR_NO_RANDOMIZE).unwrap();
 
    // Execute binary replacing the currently running code
    Command::new("/home/carstein/sample").exec();
 
    exit(0);
}

这段代码有几个问题:首先,它又长又复杂,而且虽然它使用命令模块,但是它并没有利用它提供的所有可能性。最重要的是,我们手动调用fork()函数,这看起来就像是带有借用检查器的C一样。总之我们可以做的更好,如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
 
fn main() {
    let child = unsafe {Command::new("/home/carstein/sample")
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .pre_exec(|| {
            personality(linux_personality::ADDR_NO_RANDOMIZE)
            .expect("[!] cannot set personality");
            Ok(())
        })
        .spawn()
        .expect("[!] Failed to run process")
    };
 
    println!("Started process with PID: {:?}", child.id());
}

在我看来,更新后的版本更优秀——我们正在充分利用Command的功能。pre_exec()函数和spawn()函数需要特别注意,第一个函数接受闭包并在运行主代码之前执行所有封闭的指令;第二个函数负责将目标二进制文件作为子进程运行。由于缺乏对pre_exec()的了解,才会导致第一段代码看起来如此奇怪。
还有一件事儿需要解释一下,特别是如果你打算进行更多的系统编程;你可能想知道为什么我们要导入std::os::unix::process::CommandExt;乍一看我们好像没有在使用它;原因很简单——std::os::unix::process::CommandExt只包含支持的环境中的基本功能,如果你想使用某些操作系统特有的功能,比如用给定的uid运行程序,或者像是我们的例子中,在程序的主要功能开始之前执行一些系统调用,则需要加载第三方扩展库。

检查状态

假设你想看看程序运行后发生了什么,你当然可以统统过调用child.wait()和检查退出码来检查,但这种方法并没有告诉我们更多关于程序终止和各种异常方式,所以我想尝试使用waitpid()代替

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// this code goes after starting the process
    match waitpid(Pid::from_raw(child.id() as i32), None) {
        Ok(WaitStatus::Exited(pid, status)) => {
            println!("Program {} exited normally with code {}",pid, status);
        }
        Ok(WaitStatus::Signaled(pid, signal, core)) => {
            println!("Program {} was terminated by signal {} (core dumped: {})",
        pid, signal, core);
        }
        Ok(status) => println!("Status: {:?}", status),
        Err(err) => {
            println!("We've encountered some kind of error: {:?}", err);
        }
    }

在这里,除了正常的程序终止方式外,我们还检查终止的原因是否是某种信号,例如:SIGSEGV或SIGABRT。如果想要获取有关可能情况的完整列表,我强烈建议你应该查看文档
不知你是否注意到这段代码和先前版本的不同之处,匹配指令不需要重新组装一个嵌套树,更扁平的代码可以大大提高阅读性。

追踪

这段代码上次的作用是为我的fuzzer做一个仪表模块,这次依旧是如此;我们可以在pre_exec块增加一条指令来启用进程跟踪

1
2
3
4
5
6
7
8
9
// Partial snippet of code that normally should be chained together
// with the Command::new
    .pre_exec(|| {
        ptrace::traceme()
        .expect("[!] cannot trace process");
        personality(linux_personality::ADDR_NO_RANDOMIZE)
        .expect("[!] cannot set personality");
        Ok(())
    })

设置断点

如果你尝试运行这段代码,那么waitpid()会报告一个SIGTAP,我们需要明确的处理这个问题——主要是因为接下来要做不少工作

1
2
3
4
5
6
7
8
9
10
11
// This is part of the waitpid() match instruction
        Ok(WaitStatus::Stopped(pid, signal)) => {
            println!("Program {} received {} event", pid, signal);
            handle_sigstop(pid);
        }
 
// This is a separate function that should be defined outside of main
fn handle_sigstop(pid: Pid) {
    let regs = ptrace::getregs(pid).unwrap();
    println!("Hit breakpoint at 0x{:x}", regs.rip);
}

现在我们已经成功附加到一个正在运行的进程了,接下来需要设置一些断点;设置断点所用的函数并不难

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn set_breakpoint(pid: Pid, addr: u64) -> u64 {
  // Read 8 bytes from the process memory
  let value = ptrace::read(pid, (addr) as *mut c_void).unwrap() as u64;
 
  // Insert breakpoint by write new values
  let bp = (value & (u64::MAX ^ 0xFF)) | 0xCC;
 
  unsafe {
      ptrace::write(pid, addr as *mut c_void, bp as *mut c_void).unwrap();
  }
 
  // Return original bytecode
  value
}

正如我之前解释的那样——为了在给定地址设置断点,我们需要用ptrace::read从内存中读取8个字节,将第一个字节(小端)设置为0xCC,并使用ptrace::write将其写回进程内存。原始字节码被返回给调用者,这样我们就可以在以后处理给定的断点时恢复它
这种操作是十分危险的,一旦操作失误,Rust是不会报错的
如果你对软件断点的工作原理感兴趣,可以在这篇文章中文版)中了解到更多信息
我们需要设计一个清除函数,在命中断点后清除它

1
2
3
4
5
6
fn remove_breakpoint(pid: Pid, addr: u64, orig_value: u64) {
    unsafe {
        // Restore original bytecode
        ptrace::write(pid, addr as *mut c_void, orig_value as *mut c_void).unwrap();
    }
}

重写代码

关于处理断点的代码我已经在之前文章中文版)里提到过了,不过这里为了方便其他人理解,我还是决定将这段代码放上来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn handle_sigstop(pid: Pid, saved_values: &HashMap<u64, u64>) {
    let mut regs = ptrace::getregs(pid).unwrap();
    println!("Hit breakpoint at 0x{:x}", regs.rip - 1);
 
    match saved_values.get(&(regs.rip - 1)) {
        Some(orig) => {
            restore_breakpoint(pid, regs.rip - 1, *orig);
 
            // rewind rip
            regs.rip -= 1;
            ptrace::setregs(pid, regs).expect("Error rewinding RIP");
        }
        None => print!("Nothing saved here"),
    }
 
    ptrace::cont(pid, None).expect("Restoring breakpoint failed");
}

结语

我希望这篇文章能够弥补前一篇文章的糟糕质量。当涉及到Rust(同样也适用于其他编程语言)时,你需要从别人的代码中学习一些代码经验(我从Brandon Falk那里学到了很多)。我打算多写一些关于系统编程的文章,但是写的越多我越烦躁,每次在使用第三方库时,我就感觉我在写一个带有借用检查器的C;尽管如此,我还是会分享一些关于如何围绕这些低级概念编写安全包装的技巧
正如开头承诺的那样——你可以在我写的rfuss2中看到上述代码的大部分功能

译者言

因本人翻译水平有限,如有错误之处,请斧正
原文链接


[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)

收藏
免费 3
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回
//