远程SHELL中进程因TCP连接中断而失去控制的预防及救急方案

在一个SSH会话里执行vi,后因TCP连接中断而失去控制。重新登录后发现原SSH会话对应的伪终端还在,其中的vi进程也在。有什么办法重新获取对vi的控制?本文通过设计一个实验复现问题并给出救急方案。另外,本文介绍了两个成熟的工具screenify、reptyr,第一次听说的朋友会感谢我的。

☆ 原始问题

在一个SSH会话里执行vi,后因TCP连接中断而失去控制。重新登录后发现原SSH会话对应的伪终端还在,其中的vi进程也在。有什么办法重新获取对vi的控制?

这种情况一般是单向TCP故障所致,即服务端没有收到FIN或RST,客户端单方面中止了TCP连接,现实中并不罕见。

1) 模拟场景

设计一个实验确保精确复现这种情况。

服务端是位于Guest中的Linux,客户端是Host中的SecureCRT。多登录几个SSH会话,其中一个SSH会话中执行”vi some.txt”。在VMware中断开虚拟网卡,在Host中用Tcpview切断vi进程所在SSH会话对应的TCP连接,由于虚拟网卡已断开,Guest中的SSH会话不会收到RST或FIN,而Host中的SecureCRT会收到。在其他SSH会话中用netstat、pstree、ps等工具确认目标SSH会话及vi进程仍在。

2) vi本身的swap机制

如果启动vi时没有指定-n,缺省有swap文件用于crash后的恢复。

它会自动从.some.txt.swp中恢复内容到some.txt,之后可以删除swap文件。

此处不考虑vi本身的这种恢复机制,考虑更普遍情形。

☆ 预防措施

1) screen

以前跑oclHashcat-plus时我就碰上过客户端单方面中止TCP连接的事,当时周大给我推荐了screen。

简单演示一下screen:

在另一个伪终端里恢复对vi的控制:

可以简单地”screen -r”、”screen -x”。看到vi界面,退出vi后检查当前伪终端:

退出screen状态,可以exit,也可Ctrl-D。

2) tmux

简单演示一下tmux:

在另一个伪终端里夺取对vi的控制:

看到vi界面,退出vi后检查当前伪终端:

后果与screen类似。

退出tmux状态,可以exit,也可Ctrl-D。

3) screen vs tmux

这是二者的比较:

http://www.wikivs.com/wiki/screen_vs_tmux

☆ 救急方案

screen、tmux要求提前考虑到风险,它们都是预防措施,非原始问题的答案。原始问题发生时,显然没有提前进入screen、tmux状态,还有救吗?

1) cryopid

CryoPID允许捕捉正在运行中的进程状态并将之保存到文件中,将来利用该文件恢复进程状态,甚至可以在系统重启后或迁移至另一台主机时生效。简单理解成进程级快照,不过很怀疑它的实用性,而且没能编译成功。

2) screenify(可用)

脚本编写年代过早,对于较新版本GDB,需要做点小修改,下面是我改过的:

GDB调试目标进程;用TCGETS取stdin、stdout、stderr的终端属性,这个应该就是tcgetattr();关闭原来的0/1/2号句柄;分三次打开当前SHELL所在伪终端,正常情况下它们的句柄依次是0/1/2;用TCSETS重设stdin、stdout、stderr的终端属性,这个应该就是tcsetattr();至此,目标进程的stdin、stdout、stderr转移到当前SHELL所在伪终端。

参看ioctl_tty(2)。

脚本中的三次open()是在目标进程空间中执行的,受制于目标进程的权限,如果当前SHELL与目标进程均属于同一用户,不存在问题。假设目标进程属于scz,当前SHELL属于root,open()会失败(返回-1),这是个小坑。

这种只依赖GDB的技术方案有一些限制,参看:

《2.12 在GDB中调用被调试进程空间中的函数》
《2.19 在GDB里如何搜索内存》
《2.31 GDB中如何调用指定动态链接库中的导出函数》
《2.62 GDB条件断点中进行字符串比较》

随便编辑点内容,然后切到另一个伪终端:

夺取对vi的控制:

按ESC之后再Ctrl-L刷新屏幕,看到vi界面,可以正常操作并保存退出。此时/dev/pts/3上的bash已经无法工作,但vi正常退出,之前编辑的内容得以保存。

3) retty

A: Petr Baudis, Jan Sembera 2006

retty attach processes running on other terminals

http://pasky.or.cz//dev/retty/
http://pasky.or.cz//dev/retty/retty-1.0.tar.bz2

retty本质上同screenify,我未实测。

4) injcode

A: Thomas Habets 2009-03-21

Moving a process to another terminal

https://blog.habets.se/2009/03/Moving-a-process-to-another-terminal.html
http://github.com/ThomasHabets/injcode
git clone git://github.com/ThomasHabets/injcode.git

2009年之后再未更新。

5) neercs

A: Sam Hocevar, Jean-Yves Lamoureux, Pascal Terjan

http://caca.zoy.org/wiki/neercs
http://caca.zoy.org/wiki/neercs?format=txt
http://caca.zoy.org/wiki/neercs/devel
git://git.zoy.org/neercs.git
http://caca.zoy.org/git/neercs.git

neercs比较复杂,我未实测。

6) reptyr(推荐)

A: Nelson Elhage 2011

reptyr: Attach a running process to a new terminal – [2011-01-21]

https://blog.nelhage.com/2011/01/reptyr-attach-a-running-process-to-a-new-terminal/
http://github.com/nelhage/reptyr
https://github.com/nelhage/reptyr.git

reptyr: Changing a process’s controlling terminal – [2011-02-08]

https://blog.nelhage.com/2011/02/changing-ctty/
(介绍reptyr修改目标进程控制终端的技术原理)

New reptyr feature: TTY-stealing – [2014-08-20]

https://blog.nelhage.com/2014/08/new-reptyr-feature-tty-stealing/

Reptyr Move A Running Process From One Terminal To Another Without Closing It – [2017-02-26]

https://www.ostechnix.com/reptyr-move-running-process-new-terminal/

对less使用screenify,less仍从旧终端读取输入。对ncurses程序使用screenify,无法调整窗口大小。对程序使用screenify,新终端上Ctrl-C无效。reptyr解决了这些问题。

reptyr可以在i386、x86_64、ARM上运行。

常见使用方式:

Debian有这个包,说明reptyr已成为业界通用工具。万一发行版不带reptyr,就自己编译源码吧。

6.1) reptyr(1)


-T

reptyr不是用ptrace(2)调试目标进程,而是试图找出目标进程对应的terminal emulator并劫持mater pty。这种模式更可靠,适用性更强。此时可以更改目标进程所在session的所有进程的CTTY。缺点是,除非以root身份执行reptyr,否则不能用于sshd(8)的子进程。

-l, -L [COMMAND [ARGS]]

此时没有目标进程。这种模式将创建新的pty对,在新master pty与当前终端之间进行数据转发,显示新slave pty的名字(/dev/pts/N),新slave pty没有进程与之关联。假设正用gdb调试某进程,新slave pty可做为”set inferior-tty”的参数,这比被调试进程直接使用当前终端要好。

(gdb) help set inferior-tty

Set terminal for future runs of program being debugged.
Usage: set inferior-tty [TTY]

If TTY is omitted, the default behavior of using the same terminal as GDB is restored.

如果指定了COMMAND、ARGS,将做为reptyr的子进程运行,其进程空间环境变量REPTYR_PTY指向新slave pty。-L相比-l,前者会将子进程的0、1、2号fd指向新slave pty,子进程会在一个新session中运行,其CTTY对应新slave pty。

-s

缺省情况下,reptyr只会让目标进程中确实与CTTY相关联的fd指向新终端。指定 -s后,reptyr死活将目标进程中的0、1、2号fd指向新终端,即使目标进程本来没有CTTY。

一般情况下用不着-s,用reptyr时,目标进程很大可能是交互式进程。

-v

显示版本

-h

显示帮助

-V

输出冗余调试信息

NOTES

reptyr使用ptrace(2)调试目标进程。在Ubuntu Maverick及更高版本上,出于安全考虑缺省禁止这种行为。可以临时解禁:

也可以编辑/etc/sysctl.d/10-ptrace.conf永久解禁。

BUGS

如果目标进程的屏幕未能重绘,按Ctrl-L

假设目标进程对stdin使用epoll(),reptyr并未更新epoll()所用数据,epoll()仍将访问原来的stdin。

6.2) reptyr原理简介

reptyr用ptrace(2)调试目标进程(vi),利用一些Hacking技术在vi进程空间里执行由reptyr提供的代码,比如打开新的伪终端,利用dup(2)使之变成vi进程的stdout、stderr。相比screenify,reptyr更改了vi进程的控制终端(CTTY),于是支持对目标进程Ctrl-C、Ctrl-Z。同一session中的所有进程共用同一个CTTY。

参看ioctl_tty(2)

TIOCSCTTY int arg

修改主调进程的CTTY。主调进程必须是session leader,同时不能已经拥有CTTY。此时这样调用:

如果fd已经是某个session的CTTY,ioctl()失败(EPERM),除非主调进程拥有CAP_SYS_ADMIN权限且arg等于1,此时会抢夺CTTY,原session中所有进程将失去fd对应的CTTY。此时这样调用:

从bash中启动vi,bash是session leader,vi是process group leader,该进程组只包含vi进程。为了在vi中调用ioctl(TIOCSCTTY),须设法让vi成为session leader。

参看setsid(2)

EPERM

主调进程PID等于某个PGID,即主调进程是process group leader时,setsid()失败。

vi现在是process group leader,无法调用setsid(2)。可以fork(),子进程仍在同一session、同一进程组,但不是process group leader,该子进程可以setsid()。但fork()后杀掉父进程的做法有潜在风险,谁知道vi有没有依赖PID的行为。看有无其他办法更改vi的PGID,使得vi不再是process group leader。

参看setpgid(2)

setpgid( pid, pgid );

bash处理管道符时会用setpgid()将指定进程移入指定进程组。这个操作要求pgid与pid位于同一session(参看setsid(2)、credentials(7))。

需要在vi所在session中找一个进程组,把vi移入该进程组,使得vi可以调用setsid()。bash似乎是个候选者,但我们采用更直接的办法,创建一个新进程组。在vi进程空间中fork(2),同时用ptrace(2)调试子进程。让子进程调用setpgid()创建新进程组,将父进程移入该新进程组,父进程中的vi可以调用setsid()创建新session,父进程成为session leader,父进程调用ioctl(TIOCSCTTY)指定新的CTTY。

injcode、neercs、reptyr使用同样的技术更改目标进程的CTTY。

6.3) “reptyr -T”原理简介

“reptyr -T”使用了新技术,劫持目标进程关联的master pty。

不使用-T时,reptyr更改单个目标进程的slave pty。使用-T时,reptyr尝试寻找目标进程对应的terminal emulator,用ptrace(2)调试后者(而不是目标进程),寻找master pty fd,利用AF_UNIX、SCM_RIGHTS将master pty fd传递到reptyr进程。reptyr在terminal emulator进程空间中更改master pty fd,使之指向/dev/null,最后从terminal emulator detach。

接着reptyr扮演terminal emulator的角色,从前述master pty fd读取output并写到当前终端,从当前终端读取input并写到前述master pty fd。

假设terminal emulator是sshd(8)的子进程,sshd会调用setuid(2)以匹配登录帐号。Linux禁止ptrace(2)调试这种调用过setuid(2)的进程,除非以root身份执行reptyr。

☆ 伪终端

单说解决原始问题,不需要深入了解伪终端内部细节,如果充满好奇心,可以继续阅读如下四个链接:

A Brief Introduction to termios – [2009-12-22]

https://blog.nelhage.com/2009/12/a-brief-introduction-to-termios/

A Brief Introduction to termios: termios(3) and stty – [2009-12-30]

https://blog.nelhage.com/2009/12/a-brief-introduction-to-termios-termios3-and-stty/

A Brief Introduction to termios: Signaling and Job Control – [2010-01-11]

https://blog.nelhage.com/2010/01/a-brief-introduction-to-termios-signaling-and-job-control/

The TTY demystified – [2008-07-25]

http://www.linusakesson.net/programming/tty/index.php

(介绍TTY的历史及架构变迁,比如PTY如何出现)

简译了对我本人有用的部分内容,可能有错误。

1) pty(7)/tty(4)

右侧允许多个进程连接同一个slave pty,不一定是bash。

2) termios(3)

emacs、vi之类的程序使用了curses。可以通过struct termios调整termios的行为。

是否允许流控。缺省启用,master pty收到Ctrl-S后,slave pty不再从右侧接收任何输出,向slave pty的write()操作将阻塞,直到master pty收到Ctrl-Q,恢复正常。

IUTF8 in c_iflag

IUTF8告诉termios输入流是UTF-8编码过的,处理退格删除时以单个UTF-8字符为单位进行删除。

OLCUC in c_oflag

Map[s] lowercase characters to uppercase on output

control chars

第一次接触这些东西是1998年,当时进行curses编程,与西门子某设备进行串口通信。

3) stty(1)

stty封装了对tcgetattr()、tcsetattr()的调用。

stty -a

以人类可读方式显示struct termios

stty -isig

复位ISIG,此时无法Ctrl-C中止进程。

stty intr ^G

c_cc[VINTR] = 0x7;

Ctlr-G产生SIGINT

stty -ixon stop undef

复位IXON

c_cc[VSTOP] = 0;

stty -a -F /dev/pts/N

查看指定伪终端

bash有自己的termios设置,从bash中启动其他进程时,bash会将控制终端的termios设置恢复回去。所以在bash中直接stty与从其他终端stty -F看到的不一致。

4) ioctl(2)/ioctl_tty(2)

5) Sessions and Process Groups

每个session有一个控制终端(CTTY)。单个进程可以打开多个终端,但只有CTTY可以进行任务控制(job control),比如Ctrl-Z。一个终端最多只能成为一个session的CTTY。某进程调用setsid(2)创建新session的同时,会失去原有的CTTY。若某进程没
有CTTY,当它不带O_NOCTTY标志打开某终端时,该终端自动成为其CTTY。

每个CTTY只有一个前台进程组,同一session中的其他进程属于后台进程组。

CTTY产生的控制信号不是发往单个进程,而是发往前台进程组(中的所有进程)。

前台进程组可以任意读写CTTY,可以对CTTY调用tcsetattr()。

后台进程组中的进程试图读CTTY时,该后台进程组将收到SIGTTIN。后台进程组中的进程可以写CTTY,除非c_lflag中TOSTOP置位,此时该后台进程组将收到SIGTTOU。后台进程组中的进程对CTTY调用tcsetattr()时,该后台进程组将收到SIGTTOU。

session中的进程可以调用tcsetpgrp()设置前台进程组,所受限制同前述tcsetattr()。

关于SIGHUP,参看:

《24.3 如何编写daemon程序》

一般来说,有两种典型的与SIGHUP信号相关的情形。

假设某session有控制终端,当session leader终止时,系统会向该session前台进程组中所有进程及后台进程组中处于”停止”状态的每个进程分发SIGHUP信号。

如果某进程组中有一个进程,其父进程属于同一会话(session)的另一个进程组,则该进程组不是”孤儿进程组”,反之该进程组称为”孤儿进程组”。

APUE 9.10指出,当某进程的终止导致一个新的”孤儿进程组”产生,系统会向这个新的”孤儿进程组”中处于”停止”状态的每个进程分发SIGHUP信号,然后分发SIGCONT信号。那些未处于”停止”状态的进程不会收到这两个信号。

6) Job control

任务就是进程组的别称。bash有个内部命令jobs,”help jobs”了解细节。

假设在bash中执行”foo | bar | grep baz”,bash会调用setpgid(),把这三个进程置于同一进程组,接着调用tcsetpgrp()使之成为前台进程组,最后调用waitpid()。此时Ctrl-C会杀死所有三个进程。按下Ctrl-Z,这三个进程都会被挂起,bash对waitpid()的调用将返回,bash恢复自己成前台进程组,这三个进程所在进程组变成后台进程组。

在bash中使用”bg %n”命令时,bash调用killpg(2)向指定后台进程组发送SIGCONT。后台进程组试图读CTTY时,会收到SIGTTIN,bash的wait*()会监控到后台进程组的状态变化。

在bash中使用”fg %n”命令时,bash调用tcsetpgrp()将指定进程组变成前台进程组。

7) 信号与任务控制示例

假设你正在用emacs编辑大文件,光标位于屏幕中部某处,此时emacs正对该文件进行搜索、替换操作。按下Ctrl-Z,emacs所在前台进程组收到SIGTSTP。

emacs的SIGTSTP信号句柄得到执行,通过向CTTY写入相应控制序列移动光标至屏幕最后一行。接着emacs向自己所在前台进程组发送SIGSTOP。

emacs现在被挂起,session leader收到SIGCHLD,知道emacs状态发生变化。当前台进程组中所有进程被挂起后,session leader保存当前termios设置,以备将来恢复用。session leader调用tcsetpgrp()将自身所在进程组设置成前台进程组,输出形如”[1]+ Stopped”的信息,通知用户有任务被挂起。

ps(1)可以看到emacs处在停止状态(T)。可以用bash内置命令bg使emacs继续执行,也可以用kill(1)向emacs发送SIGCONT,emacs的SIGCONT信号句柄得到执行,这将试图重绘emacs的GUI。但是,emacs现在位于后台进程组,写CTTY导致emacs收到SIGTTOU,emacs再次停止运行,session leader再次收到SIGCHLD,再次输出”[1]+ Stopped”。

用bash内置命令fg,bash恢复之前保存的termios设置,调用tcsetpgrp()将emacs所在进程组设置成前台进程组,向前台进程组发送SIGCONT。emacs的SIGCONT信号句柄得到执行,重绘emacs的GUI。

8) setsid(1)

这是个外部命令

$ dpkg -S $(which setsid)
util-linux: /usr/bin/setsid

————————————————————————–
NAME

setsid – 在新session中运行指定程序

SYNOPSIS

setsid [options] program [arguments]

DESCRIPTION

setsid在一个新session中执行指定程序。

如果setsid本身已经是process group leader,会调用fork(2)创建子进程,在子进程中调用setsid(2),否则直接调用setsid(2);最后调用exec*()执行指定程序。如果使用–fork参数,则总是创建子进程。考虑setsid不从bash启动,而是由其他进程或脚本启动。

OPTIONS

-c, –ctty

将指定程序的CTTY设置成当前终端

一个终端最多只能成为一个session的CTTY,ioctl(TIOCSCTTY)会抢夺CTTY。

-f, –fork

总是创建新(子)进程

-w, –wait

等待指定程序执行结束,其退出码做为setsid命令的退出码

-V, –version

显示版本

-h, –help

显示帮助

SEE ALSO

setsid(2)

“setsid -f”时死活fork(),现在libc中的fork(3)由clone(2)实现,尽管fork(2)仍然可用。setsid.log.8549中没有getpgrp()、getpid(),因为-f参数不需要检查setsid是否是process group leader。

9) jobs/disown/nohup

bash(1)关于SIGHUP的内容不清晰,下面加以补充。

交互式(无论登录、非登录)bash收到SIGHUP会退出,在其结束前会向所有前后台任务(无论状态)发SIGHUP。假设此时有停止状态的后台任务,交互式bash可能还会向其发送SIGCONT,但我从未观察到过。

交互式(无论登录、非登录)bash主动exit时,不会向运行状态的后台任务发送任何信号,会向停止状态的后台任务发送SIGTERM,不是SIGHUP。

假设huponexit被启用(缺省是off),交互式登录bash在退出前(无论是收到SIGHUP还是主动exit)会向所有前后台任务发送SIGHUP,包括运行状态的后台任务。huponexit只影响交互式登录bash,不影响交互式非登录bash。比如SSH登录后的bash受huponexit影响,在登录bash中新启动的bash不受huponexit影响。这些结论可以用strace确认。

“disown %n”是bash内置命令,其作用是将后台任务从bash的任务列表中移除,之后无法对被移除的后台任务使用fg、bg命令,同时阻止交互式bash收到SIGHUP之后向被移除任务发送SIGHUP。

“disown -h”只对付SIGHUP,不从任务列表中移除指定任务,此时可以fg、bg。

disown不会影响PID、PPID、PGID、SID,也不剥离CTTY。

若某停止状态的后台任务事先被disown过,收到SIGHUP的交互式bash确实不会向之发送SIGHUP,但是kernel会向之发送SIGHUP,因为此时有新的”孤儿进程组”产生。这意味着disown过的停止状态的后台任务仍将被杀,strace可能看到:

对运行状态的任务(无论前后台)使用disown才稍有意义,disown其实十分鸡肋。

参看:

Difference between nohup, disown and & – [2010-11-09]

https://unix.stackexchange.com/questions/3886/difference-between-nohup-disown-and

对比&、nohup、disown的使用,不过有些内容术语混乱、表述不严谨,请自行修正。

nohup启动进程之前重定向stdout、对SIGHUP使用SIG_IGN(被启动进程可能更改这种设置)。nohup并不影响session、CTTY。

&只是将进程丢入bash后台运行,不影响stdin、stdout、stderr、CTTY。

☆ 结束语

原始问题在过去经常碰上,始终没有深究过救急方案,只是从原理上知道动用llkm、tty hijacking技术可能可能解决,因为以前搞过tty hijacking。但涉及内核态编程,稳定性、可移植性不高。

最近有同事找过来问这个事,当时想当然地以为没有成熟工具,谁知道在用户态有screenify、reptyr这两种成熟工具,尤其后者进了Debian发行版。

20年前搞过curses编程,当时仅限于利用各种API实现功能,没有从更深处理解伪终端机制,这次一并学习了一点点。本文没有原创技术点,汇总从2004年至今在互联网上能找到的关于此问题的绝大多数有价值的讨论。本文价值在于将这些零散分布的有用信息归档一处。建议直接阅读英文原文,我是反复过了好几遍。

那些第一次听说screenify、reptyr的人,你们会感谢我的。

发表评论