├── README.md ├── doc ├── after_hijack_dark.png ├── after_hijack_lite.png ├── after_hijack_reflect_dark.png ├── after_hijack_reflect_lite.png ├── before_hijack_dark.png ├── before_hijack_lite.png ├── diagram.psd ├── terminal.gif └── terminal.psd └── termijack.py /README.md: -------------------------------------------------------------------------------- 1 | # Terminal Hijacker # 2 | 3 | ## Introduction ## 4 | 5 | ![terminal](doc/terminal.gif) 6 | 7 | TermiJack hijacks the standard streams (stdout, stdin, and/or stderr) from an already 8 | running process and silently returns them back after finishing. While this 9 | script is running and attached to another process, the user may interact with 10 | the running process as if they were interacting with the original terminal. 11 | 12 | This script also provides the ability to mirror hijacked streams. In the case 13 | of standard input, this means that inputs from both this terminal and the 14 | remote terminal will be forwarded to the target process. Similarly, standard 15 | output and error coming from the target process will be forwarded to both this 16 | terminal and the remote terminal. 17 | 18 | While gdb is being used to hijack standard streams, there may be a small 19 | latency during the transition where the target process is paused. Do _not_ use 20 | this script on time-critical processes. Also, this script may need to be run as 21 | root in order for gdb to do its business. 22 | 23 | Lastly, this script performs poorly with programs using either the ncurses or 24 | readline GNU libraries due to the special way they interact with input/output 25 | streams. Support for them may be added in the future. 26 | 27 | Requires the GNU Debugger (gdb) in order to run. 28 | 29 | 30 | ## Theory ## 31 | 32 | Typically, the standard streams (stdin, stdout, stderr) are connected to a 33 | virtual terminal like ```/dev/pts/23``` as show below: 34 | 35 | ![before_hijack](doc/before_hijack_lite.png) 36 | 37 | Using gdb to intercept the target process, we can use syscalls (open, fcntl) 38 | to create a set of named pipes that will act as the intermediate socket between 39 | the target process and the hijacker script. Other syscalls (dup, dup2) are used 40 | to clone the original standard streams to temporary place-holders and to swap 41 | the file descriptors of the named pipes and standard streams. 42 | 43 | In the situation where we only hijack the standard streams and don't reflect 44 | the to/from the original streams, this setup looks something like the following: 45 | 46 | ![after_hijack](doc/after_hijack_lite.png) 47 | 48 | The termijack script also allows the ability to mirror the standard streams 49 | to/from the hijacked process. This means that the hijacked stdin and hijacker's 50 | stdin will be multiplexed to the target process. Additionally, and stdout or 51 | stderr coming from the hijacked process will be sent to both the hijacked 52 | virtual terminal and to the hijacker's virtual terminal. This setup looks 53 | something like the following: 54 | 55 | ![after_hijack_reflect](doc/after_hijack_reflect_lite.png) 56 | 57 | Of course, at the very end, when the termijack script detaches from the target 58 | process, it will undo all of the shenanigans and close file descriptors that it 59 | opened. Ideally, it's operation should be very surreptitious. 60 | 61 | 62 | ## Usage ## 63 | 64 | Hijack stdin, stdout, and stderr: 65 | 66 | * ```./termijack.py -ioe $TARGET_PID``` 67 | 68 | Hijack stdin, stdout, and stderr. Also, reflect them back to the target process: 69 | 70 | * ```./termijack.py -IOE $TARGET_PID``` 71 | -------------------------------------------------------------------------------- /doc/after_hijack_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsnet/termijack/994baa57737774d74bb22a05ac04a9a9fcc9868e/doc/after_hijack_dark.png -------------------------------------------------------------------------------- /doc/after_hijack_lite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsnet/termijack/994baa57737774d74bb22a05ac04a9a9fcc9868e/doc/after_hijack_lite.png -------------------------------------------------------------------------------- /doc/after_hijack_reflect_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsnet/termijack/994baa57737774d74bb22a05ac04a9a9fcc9868e/doc/after_hijack_reflect_dark.png -------------------------------------------------------------------------------- /doc/after_hijack_reflect_lite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsnet/termijack/994baa57737774d74bb22a05ac04a9a9fcc9868e/doc/after_hijack_reflect_lite.png -------------------------------------------------------------------------------- /doc/before_hijack_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsnet/termijack/994baa57737774d74bb22a05ac04a9a9fcc9868e/doc/before_hijack_dark.png -------------------------------------------------------------------------------- /doc/before_hijack_lite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsnet/termijack/994baa57737774d74bb22a05ac04a9a9fcc9868e/doc/before_hijack_lite.png -------------------------------------------------------------------------------- /doc/diagram.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsnet/termijack/994baa57737774d74bb22a05ac04a9a9fcc9868e/doc/diagram.psd -------------------------------------------------------------------------------- /doc/terminal.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsnet/termijack/994baa57737774d74bb22a05ac04a9a9fcc9868e/doc/terminal.gif -------------------------------------------------------------------------------- /doc/terminal.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsnet/termijack/994baa57737774d74bb22a05ac04a9a9fcc9868e/doc/terminal.psd -------------------------------------------------------------------------------- /termijack.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Written in 2012 by Joe Tsai 4 | # 5 | # =================================================================== 6 | # The contents of this file are dedicated to the public domain. To 7 | # the extent that dedication to the public domain is not available, 8 | # everyone is granted a worldwide, perpetual, royalty-free, 9 | # non-exclusive license to exercise all rights associated with the 10 | # contents of this file for any purpose whatsoever. 11 | # No rights are reserved. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 15 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 16 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 17 | # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 18 | # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | # SOFTWARE. 21 | # =================================================================== 22 | 23 | import re 24 | import os 25 | import sys 26 | import time 27 | import stat 28 | import fcntl 29 | import errno 30 | import signal 31 | import tempfile 32 | import optparse 33 | import subprocess 34 | 35 | ################################################################################ 36 | ############################### Global variables ############################### 37 | ################################################################################ 38 | 39 | # Dictionary of streams to hijack 40 | # Key: 0 for stdin, 1 for stdout, 2 for stderr 41 | # Values: [local terminal file object, remote terminal file object, FIFO file object, target process original file descriptor] 42 | streams = {0:[sys.stdin,None,None,None], 1:[sys.stdout,None,None,None], 2:[sys.stderr,None,None,None]} 43 | hijack = [False, False, False] # Streams to hijack 44 | mirror = [False, False, False] # Streams to reflect 45 | pid = None # Target process 46 | tempdir = None # Temporary directory, will clean-up at the end 47 | sys_exit = False 48 | 49 | ################################################################################ 50 | ################################ Helper classes ################################ 51 | ################################################################################ 52 | 53 | class GDB_Client(): 54 | def __init__(self): 55 | # Start a GDB process 56 | self.proc = subprocess.Popen(['gdb'], stdin = subprocess.PIPE, stdout = subprocess.PIPE, stderr = subprocess.PIPE) 57 | non_blocking(self.proc.stdout) 58 | non_blocking(self.proc.stderr) 59 | 60 | # Flush initial text 61 | self.proc.stdin.write("set prompt \\033[X\\n\n") 62 | while True: 63 | try: 64 | if '\x1b[X' in readline(self.proc.stdout): break 65 | except: pass 66 | while True: 67 | try: lines += self.proc.stderr.readline() 68 | except: break 69 | 70 | def command(self, cmd): 71 | self.proc.stdin.write(cmd+'\n') 72 | lines = '' 73 | while True: 74 | line = '' 75 | try: line = readline(self.proc.stdout) 76 | except: pass 77 | if '\x1b[X' in line: break 78 | lines += line 79 | while True: 80 | try: lines += self.proc.stderr.readline() 81 | except: break 82 | return lines 83 | 84 | def close(self): 85 | self.proc.stdin.write("set confirm off\n") 86 | self.proc.stdin.write("quit\n") 87 | 88 | ################################################################################ 89 | ############################### Helper functions ############################### 90 | ################################################################################ 91 | 92 | def show_help(message): 93 | print message 94 | print "Try '%s --help' for more information" % sys.argv[0].strip() 95 | sys.exit(1) 96 | 97 | def safe_exit(ret_code = 0, message = None): 98 | if message: 99 | print message 100 | 101 | # Perform clean-up on the target process 102 | gdb = GDB_Client() 103 | gdb.command('attach %s' % pid) 104 | for stream_num in range(3): 105 | # Copy temporary holders back into original stream and close the swap 106 | if streams[stream_num][3]: 107 | ret_text = gdb.command('call dup2(%s,%s)' % (streams[stream_num][3],str(stream_num))) 108 | ret_text = gdb.command('call close(%s)' % streams[stream_num][3]) 109 | gdb.close() 110 | 111 | # Close the files opened for mirror reflection operations 112 | for stream_num in range(3): 113 | if streams[stream_num][1]: 114 | streams[stream_num][1].close() 115 | 116 | # Close each FIFO 117 | for stream_num in range(3): 118 | if streams[stream_num][2]: 119 | streams[stream_num][2].close() 120 | 121 | # Delete each FIFO and the temporary directory 122 | if tempdir: 123 | for stream_num in range(3): 124 | if streams[stream_num][2]: 125 | os.remove(os.path.join(tempdir,str(stream_num))) 126 | os.removedirs(tempdir) 127 | 128 | sys.exit(ret_code) 129 | 130 | def interrupt_handler(sig_num, frame): 131 | global sys_exit 132 | if not sys_exit: 133 | sys_exit = True 134 | safe_exit(0, "\r----------\nDetached from target process!") 135 | 136 | def check_pid(pid): 137 | try: os.kill(pid, 0) 138 | except OSError: return False 139 | else: return True 140 | 141 | def non_blocking(file): 142 | file_desc = file.fileno() 143 | file_flags = fcntl.fcntl(file_desc, fcntl.F_GETFL) 144 | fcntl.fcntl(file_desc, fcntl.F_SETFL, file_flags | os.O_NONBLOCK) 145 | 146 | def readline(file, timeout = 5): 147 | line = '' 148 | start_mark = time.time() 149 | while True: 150 | try: 151 | char = file.read(1) 152 | line += char 153 | if char == '\n': break 154 | except: 155 | if time.time() - start_mark > timeout: break 156 | return line 157 | 158 | ################################################################################ 159 | ################################# Script setup ################################# 160 | ################################################################################ 161 | 162 | epilog = """ 163 | Hijacks the standard streams (stdout, stdin, and/or stderr) from an already 164 | running process and silently returns them back after finishing. While this 165 | script is running and attached to another process, the user may interact with 166 | the running process as if they were interacting with the original terminal. 167 | 168 | This script also provides the ability to mirror hijacked streams. In the case 169 | of standard input, this means that inputs from both this terminal and the 170 | remote terminal will be forwarded to the target process. Similarly, standard 171 | output and error coming from the target process will be forwarded to both this 172 | terminal and the remote terminal. 173 | 174 | While gdb is being used to hijack standard streams, there may be a small 175 | latency during the transition where the target process is paused. Do NOT use 176 | this script on time-critical processes. Also, this script may need to be run as 177 | root in order for gdb to do its business. 178 | 179 | Lastly, this script performs poorly with programs using either the ncurses or 180 | readline GNU libraries due to the special way they interact with input/output 181 | streams. Support for them may be added in the future. 182 | 183 | Requires the GNU Debugger (gdb) in order to run. 184 | """ 185 | 186 | # Create a option parser 187 | opts_parser = optparse.OptionParser(usage = "%s [options] PID" % sys.argv[0].strip(), epilog = epilog, add_help_option = False) 188 | def func_epilog(formatter): return epilog 189 | opts_parser.format_epilog = func_epilog 190 | opts_parser.add_option('-h', '--help', action = 'help', help = 'Display this help and exit') 191 | opts_parser.add_option('-v', '--version', dest = 'version', action = 'store_true', help = 'Display the script version and exit') 192 | opts_parser.add_option('-i', '--hijack_stdin', dest = 'hijack_stdin', action = 'store_true', help = 'Hijack the standard input stream going to the target process [Default: False]') 193 | opts_parser.add_option('-o', '--hijack_stdout', dest = 'hijack_stdout', action = 'store_true', help = 'Hijack the standard output stream coming from the target process [Default: False]') 194 | opts_parser.add_option('-e', '--hijack_stderr', dest = 'hijack_stderr', action = 'store_true', help = 'Hijack the standard error stream coming from the target process [Default: False]') 195 | opts_parser.add_option('-I', '--mirror_stdin', dest = 'mirror_stdin', action = 'store_true', help = 'Mirror input streams from both local and remote terminals to the target process [Default: False]') 196 | opts_parser.add_option('-O', '--mirror_stdout', dest = 'mirror_stdout', action = 'store_true', help = 'Mirror the output stream from the target process to both the local and remote terminals [Default: False]') 197 | opts_parser.add_option('-E', '--mirror_stderr', dest = 'mirror_stderr', action = 'store_true', help = 'Mirror the error stream from the target process to both the local and remote terminals [Default: False]') 198 | (opts, args) = opts_parser.parse_args() 199 | 200 | # Display version and quit 201 | if opts.version: 202 | print "Terminal Hijacking Script 1.0" 203 | print " This is free software: you are free to change and redistribute it." 204 | print " Written in 2012 by Joe Tsai " 205 | sys.exit(0) 206 | 207 | # Check the target process argument 208 | if len(args) != 1: 209 | show_help("Invalid number of required arguments") 210 | try: 211 | pid = str(int(args[0])) 212 | except: 213 | show_help("Invalid target process: %s" % args[0]) 214 | 215 | # Check which streams to hijack (If mirror is enabled, assume a hijacking was on order) 216 | opts.hijack_stdin = True if opts.mirror_stdin else opts.hijack_stdin 217 | opts.hijack_stdout = True if opts.mirror_stdout else opts.hijack_stdout 218 | opts.hijack_stderr = True if opts.mirror_stderr else opts.hijack_stderr 219 | hijack = [opts.hijack_stdin, opts.hijack_stdout, opts.hijack_stderr] # Streams to hijack 220 | mirror = [opts.mirror_stdin, opts.mirror_stdout, opts.mirror_stderr] # Streams to reflect 221 | if True not in hijack: 222 | show_help("Must hijack at least one stream") 223 | 224 | # Interrupt handler 225 | signal.signal(signal.SIGTERM, interrupt_handler) 226 | signal.signal(signal.SIGQUIT, interrupt_handler) 227 | signal.signal(signal.SIGINT, interrupt_handler) 228 | 229 | # Set local stdin as non-blocking 230 | non_blocking(sys.stdin) 231 | 232 | ################################################################################ 233 | ################################# Script start ################################# 234 | ################################################################################ 235 | 236 | # Check that gdb is even installed 237 | try: 238 | subprocess.Popen(['gdb','--version'], stdout = subprocess.PIPE, stderr = subprocess.PIPE).wait() 239 | except: 240 | safe_exit(1, "Error: Could not find an installation of GNU Debugger (gdb) on this system") 241 | 242 | # Generated named pipes 243 | tempdir = tempfile.mkdtemp(prefix = 'termijack_') 244 | os.chmod(tempdir,0711) # Target process must be able to access this folder 245 | try: 246 | for stream_num in range(3): 247 | if hijack[stream_num]: 248 | os.mkfifo(os.path.join(tempdir,str(stream_num))) 249 | os.chmod(os.path.join(tempdir,str(stream_num)),0666) # Target process must be able to read these pipes 250 | streams[stream_num][2] = open(os.path.join(tempdir,str(stream_num)), 'w+' if (stream_num == 0) else 'r+') 251 | non_blocking(streams[stream_num][2]) 252 | except: 253 | safe_exit(1, "Error: Could not create temporary FIFO pipes") 254 | 255 | # Attach gdb to the target process 256 | gdb = GDB_Client() 257 | line = gdb.command('attach %s' % pid) 258 | if "No such process" in line: 259 | safe_exit(1, "Error: The target process does not exist") 260 | elif "Operation not permitted" in line: 261 | safe_exit(1, "Error: Attaching to target process not permitted") 262 | elif "Could not attach" in line: 263 | safe_exit(1, "Error: Could not attach to target process") 264 | 265 | # Redirect streams as necessary 266 | for stream_num in range(3): 267 | if hijack[stream_num]: 268 | # Open named pipes on target process 269 | ret_text = gdb.command('call open("%s",66)' % os.path.join(tempdir,str(stream_num))) 270 | pipe_fd = re.search(r"\$[0-9]+ = ([0-9]+)",ret_text).groups()[0] 271 | 272 | # Copy original flags to the new pipes 273 | ret_text = gdb.command('call fcntl(%s,4,fcntl(%s,3))' % (pipe_fd,str(stream_num))) 274 | 275 | # Copy original stream into temporary holders 276 | ret_text = gdb.command('call dup(%s)' % str(stream_num)) 277 | streams[stream_num][3] = re.search(r"\$[0-9]+ = ([0-9]+)",ret_text).groups()[0] 278 | 279 | # Copy new pipes into original stream 280 | ret_text = gdb.command('call dup2(%s,%s)' % (pipe_fd,str(stream_num))) 281 | 282 | # Close the opened pipe 283 | ret_text = gdb.command('call close(%s)' % pipe_fd) 284 | gdb.close() 285 | print "Attached to target process %s" % pid 286 | 287 | # Open virtual terminals for stealthy reflection tricks 288 | for stream_num in range(3): 289 | if mirror[stream_num]: 290 | stream_type = {0:'stdin', 1:'stdout', 2:'stderr'} 291 | file_real = os.path.realpath(os.path.join("/proc",pid,'fd',streams[stream_num][3])) 292 | try: 293 | if re.search("^/dev/",file_real) and stat.S_ISCHR(os.stat(file_real).st_mode): 294 | streams[stream_num][1] = open(file_real,'rw+') 295 | non_blocking(streams[stream_num][1]) 296 | else: 297 | print "Warning: The file %s does not represent a valid terminal for %s" % (file_real, stream_type[stream_num]) 298 | except OSError, ex: 299 | print "Warning: %s while accessing %s for %s" % (ex.strerror, file_real, stream_type[stream_num]) 300 | print "----------" 301 | 302 | while True: 303 | # Forward to target stdin from: 304 | if hijack[0]: 305 | # Local stdin 306 | try: 307 | streams[0][2].write(streams[0][0].read()) 308 | streams[0][2].flush() 309 | except: pass 310 | # Remote stdin 311 | try: 312 | streams[0][2].write(streams[0][1].read()) 313 | streams[0][2].flush() 314 | except: pass 315 | 316 | # Forward target stdout to: 317 | if hijack[1]: 318 | try: 319 | data = streams[1][2].read() 320 | # Local stdout 321 | if streams[1][0]: 322 | streams[1][0].write(data) 323 | streams[1][0].flush() 324 | # Remote stdout 325 | if streams[1][1]: 326 | streams[1][1].write(data) 327 | streams[1][1].flush() 328 | except: pass 329 | 330 | # Forward target stderr to: 331 | if hijack[2]: 332 | try: 333 | data = streams[2][2].read() 334 | # Local stderr 335 | if streams[2][0]: 336 | streams[2][0].write(data) 337 | streams[2][0].flush() 338 | # Remote stderr 339 | if streams[2][1]: 340 | streams[2][1].write(data) 341 | streams[2][1].flush() 342 | except: pass 343 | 344 | # Check if target process died 345 | if not check_pid(int(pid)): 346 | safe_exit(0,"\r----------\nTarget process died!") 347 | 348 | time.sleep(0.01) 349 | 350 | # EOF 351 | --------------------------------------------------------------------------------