优雅动态关闭一个运行中进程的fd

如果一个进程莫名其妙的继承了一个父进程的fd是很令人不爽的,这可能会造成依某些资源被不需要它的进程占用。
如果这个运行中的进程是敏感业务,不允许重启,那么问题会更为棘手。
本文描述了一种动态关闭运行中进程fd的方法,并探究其实现原理--如何使用ptrace去动态改变进程行为。


工具介绍

fdclose GitHub - briceburg/fdclose: attach to a process and close a file descriptor

使用方法 ./fdclose pid fd

效果:关闭pid进程的fd文件描述符

实现原理

target = ptrace_do_init(pid);
ptrace_do_syscall(target, __NR_close, fd, 0, 0, 0, 0, 0);
ptrace_do_cleanup(target);

 可以看到是调用了ptrace_do相关的函数完成了远程在pid进程内调用close系统调用的功能。

ptrace_do介绍

ptrace介绍 ptrace(2) - Linux manual page

ptrace是一个linux提供的系统调用,提供了让我们连接到一个正在运行的进程,检测/改变进程的内存/寄存器,改变进程状态等功能。strace和gdb都是用了ptrace,我们也可以使用它来实现一些实用的功能。

由于ptrace本身参数过于复杂,所以有人创建了ptrace_do库,来简化这些功能

https://github.com/emptymonkey/ptrace_do

fdclose就是使用了

ptrace_do_init
ptrace_do_syscall
ptrace_do_cleanup

这三个函数来完成了动态close一个进程中fd的功能。

下面我们通过分析ptrace_do的源码,来解释fdclose是如何完成这样的功能的。

ptrace_do源码分析

首先是ptrace_do_init

struct ptrace_do *ptrace_do_init(int pid);

 它返回一个ptrace_do的上下文,意思是连接到的进程的session,后续对于这个进程的操作或者释放都通过这个session进行

/* Basic object for keeping state. */
struct ptrace_do{
	int pid;
	
	unsigned long syscall_address;
        ...
};

在这个案例中我们最关注的对象是 syscall_address

因为我们远程执行一个系统调用的思路是这样的

1. 中断住正在运行的进程(ptrace通过sigstop信号达到这样的效果)

2. 找到一个系统调用的指令位置

3. 保存当前寄存器上下文

4. 将rip切换到系统调用指令的位置

5. 使用ptrace设置rax为__NR_close rdi为fd(这是linux系统调用前两个参数传递方式)

6. 单步执行一条指令(这时我们已经成功close了fd)

7. 断住进程

8. 使用ptrace恢复寄存器

9. 完成

所以整体的思想是找到一条我们能利用的执行系统调用的指令,然后临时将rip指向它,传入我们的fd,单步执行,再还原现场。

让我们详细看一下ptrace_do_init的实现

struct ptrace_do *ptrace_do_init(int pid){
	int retval, status;
	unsigned long peekdata;
	unsigned long i;
	struct ptrace_do *target;
	siginfo_t siginfo;
	struct parse_maps *map_current;
...
	target->pid = pid;
...
		if((retval = ptrace(PTRACE_ATTACH, target->pid, NULL, NULL)) == -1){
			
		}
		if((retval = waitpid(target->pid, &status, 0)) < 1){
			
		}
...
	if((retval = ptrace(PTRACE_GETREGS, target->pid, NULL, &(target->saved_regs))) == -1){
		
	}
	
	peekdata = ptrace(PTRACE_PEEKTEXT, target->pid, (target->saved_regs).rip - SIZEOF_SYSCALL, NULL);

	if(!errno && ((SYSCALL_MASK & peekdata) == SYSCALL)){
		target->syscall_address = (target->saved_regs).rip - SIZEOF_SYSCALL;
	// Otherwise, we will need to start stepping through the various regions of executable memory looking for 
	// a SYSCALL instruction.
	}else{
                // 枚举/proc/${pid}/maps
		
	}
	return(target);
}

 我们的目标是,连接上pid进程,保存寄存器,获取一个系统调用指令并存入target->syscall_address对象中

首相我们使用

ptrace(PTRACE_ATTACH, target->pid, NULL, NULL)

 去连接进程

waitpid(target->pid, &status, 0)

 之后马上要wait一下,为什么呢

因为在ptrace的定义中说

Attach to the process specified in pid, making it a tracee
of the calling process.  The tracee is sent a SIGSTOP, but
will not necessarily have stopped by the completion of
this call; use waitpid(2) to wait for the tracee to stop.

 就是说ptrace继续往下走之前,pid进程不一定已经被停掉了,只是被发了sigstop信号,

我们需要调用waitpid去等一下,并且在等到之后检查一下waitpid等到的原因是否是sigstop

if(!WIFSTOPPED(status)) ...

 ok, 至此我们已经成功连接上进程了,下面可以使用ptrace的其他功能去获取/修改进程的信息了。

我们要获取一个系统调用的指令,看看ptrace_do是怎么做的

怎么找这个指令呢,我们首先要知道,当前二进制程序是使用什么指令陷入内核的

https://www.felixcloutier.com/x86/syscall.htm

SYSCALL invokes an OS system-call handler at privilege level 0. 

OpcodeInstructionOp/En64-Bit ModeCompat/Leg ModeDescription
0F 05SYSCALLZOValidInvalidFast call to privilege level 0 system procedures.

根据上面的资料可以知道,编译后的程序要使用系统调用需要有0x050f这两个字节的组成的SYSCALL指令

(注意:实用int 0x80进入内核的二进制程序,这里就无能为力了,当然可以改代码实现)

所以理所当然,我们要便利整个进程空间的内存,找到第一个0x050f的内存地址,

ptrace PTRACE_PEEKTEXT 为我们提供了获取内存空间中2个字节内容的能力

PTRACE_PEEKTEXT
              Read a word at the address addr in the tracee's memory,
              returning the word as the result of the ptrace() call.

所以我们可以使用/proc/$pid/maps中的信息

找到所有的可执行的页面,遍历

而ptrace_do在某些情况下取了个巧

// If we came in from a PTRACE_ATTACH call, then it's likely we are on a syscall edge, and can save time by just
// using the one SIZEOF_SYSCALL addresses behind where we are right now.
	errno = 0;
	peekdata = ptrace(PTRACE_PEEKTEXT, target->pid, (target->saved_regs).rip - SIZEOF_SYSCALL, NULL);

	if(!errno && ((SYSCALL_MASK & peekdata) == SYSCALL)){
		target->syscall_address = (target->saved_regs).rip - SIZEOF_SYSCALL;

 看ptrace_do的注释信息:如果进程被attach了,它当前很可能是被中断在一个syscall的边界上

也就是说时间顺序应该是这样的

假设我们的进程为 A 目标进程为 B

1. A 调用ptrace 

2. ptrace退出

3. B在某个系统调用之后被挂起

4. A waitpid等到了B

所以这时B用户态的rip应该刚刚执行了syscall指令,也就是0x050f

所有这时我们只要使用rip-2就是syscall指令的一个有效地址

这样就比扫描maps的开销小了很多。

当然我们也要强行校验一下这个rip-2位置的内存是否是0x050f

否则退回到默认逻辑,遍历maps

至此,target->saved_regs 保存了B进程的寄存器上下文

target->syscall_address 保存了一个syscall指令的地址

有了这两个东西我们就可以远程执行系统调用了

ptrace_do_syscall 功能分析

先看一下函数原型

unsigned long ptrace_do_syscall(struct ptrace_do *target, unsigned long rax, \ unsigned long rdi, unsigned long rsi, unsigned long rdx, \ unsigned long r10, unsigned long r8, unsigned long r9);

看起来只要传入刚才ptrace_do_init得到的上下文,再传入想要调用的系统调用号和参数,就能执行远程调用了

那么原理是什么呢

远程执行系统调用的思路是这样的,首先将rip替换为syscall_address

我们可以使用ptrace的PTRACE_SETREGS功能完成寄存器的设置(这里我们要设置rip,rax,rdi,rax是__NR_close rdi是fd)

最终我们还要通过这个能力还原寄存器

PTRACE_SETREGS
              Modify the tracee's general-purpose or floating-point
              registers, respectively, from the address data in the
              tracer. 

使用ptrace给我们提供的PTRACE_SINGLESTEP功能进行单步执行

PTRACE_SYSCALL, PTRACE_SINGLESTEP
              Restart the stopped tracee as for PTRACE_CONT, but arrange
              for the tracee to be stopped at the next entry to or exit
              from a system call, or after execution of a single
              instruction, respectively.  (The tracee will also, as
              usual, be stopped upon receipt of a signal.)  From the
              tracer's perspective, the tracee will appear to have been
              stopped by receipt of a SIGTRAP.  So, for PTRACE_SYSCALL,
              for example, the idea is to inspect the arguments to the
              system call at the first stop, then do another
              PTRACE_SYSCALL and inspect the return value of the system
              call at the second stop.  The data argument is treated as
              for PTRACE_CONT.  (addr is ignored.)

 “or after execution of a single instruction”

这里就是我们要的功能,就是说我们通过ptrace(PTRACE_SINGLESTEP)可以单步执行指令。

在执行完这个syscall指令并确认成功后,通过saved_regs中的信息还原寄存器。

当然ptrace(PTRACE_SINGLESTEP)不一定100%成功,所以要关注一下异常的场景(见libptrace_do.c)

unsigned long ptrace_do_syscall(struct ptrace_do *target, unsigned long rax, \ unsigned long rdi, unsigned long rsi, unsigned long rdx, \ unsigned long r10, unsigned long r8, unsigned long r9){ ... if((retval = waitpid(target->pid, &status, 0)) < 1){ ... } if(status){ ... 在非0的情况下有可能会重试 } ... return(attack_regs.rax); }

然后,我们通过ptrace提供的PTRACE_GETREGS功能获取远程执行系统调用的return值

PTRACE_GETREGS
              Copy the tracee's general-purpose or floating-point
              registers, respectively, to the address data in the
              tracer. 

 最后我们再使用PTRACE_SETREGS还原寄存器

ptrace(PTRACE_SETREGS, target->pid, NULL, &(target->saved_regs)

 至此,我们已经成功的执行系统调用关闭了文件描述符了。

最后我们还要从目标进程dettach出来

ptrace_do_cleanup(target);

 实用ptrace提供的PTRACE_DETACH能力完成

PTRACE_DETACH
              Restart the stopped tracee as for PTRACE_CONT, but first
              detach from it. 

 通过描述可以看到,进程继续运行了。

总结

本文小试牛刀,描述了远程动态执行close一个fd的方法。

另外还有很多场景,比如如何使一个已经把标准输出重定向到/dev/null的进程重新将标准输出打印出来

linux - GDB:临时重定向目标标准输出 - IT工具网

可见ptrace功能强大,只要有权限,几乎可以对一个正在执行的进程做任何操作。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值