3

I am developing a robot that accepts commands from network (XMPP) and uses subprocess module in Python to execute them and sends back the output of commands. Essentially it is an SSH-like XMPP-based non-interactive shell.

The robot only executes commands from authenticated trusted sources, so arbitrary shell commands are allowed (shell=True).

However, when I accidentally send some command that needs a tty, the robot is stuck.

For example:

subprocess.check_output(['vim'], shell=False)
subprocess.check_output('vim', shell=True)

Should each of the above commands is received, the robot is stuck, and the terminal from which the robot is run, is broken.

Though the robot only receives commands from authenticated trusted sources, human errs. How could I make the robot filter out those commands that will break itself? I know there is os.isatty but how could I utilize it? Is there a way to detect those "bad" commands and refuse to execute them?

TL;DR:

Say, there are two kinds of commands:

  • Commands like ls: does not need a tty to run.
  • Commands like vim: needs a tty; breaks subprocess if no tty is given.

How could I tell a command is ls-like or is vim-like and refuses to run the command if it is vim-like?

Zhuoyun Wei
  • 746
  • 1
  • 7
  • 14
  • why do you use `check_output()`? Do you need to capture stdout of an arbitrary command? I would try to [provide pseudo-tty whether the subprocess requires it or not using `pexpect` or `pty.spawn()`](http://stackoverflow.com/a/25945031/4279) – jfs May 09 '15 at 12:19
  • You could also [use `pty` with `subprocess` (for greater flexibility)](http://stackoverflow.com/a/12471855/4279) – jfs May 09 '15 at 12:26
  • @J.F.Sebastian Providing the command with a pseudo-tty seems like good idea but I am having no success trying this out. I am not familiar with fds, are there any differences in terms of fds between commands like `ls` and `vim`? Could I tell whether the command is "bad" (needs a tty) from their fds? – Zhuoyun Wei May 09 '15 at 15:42
  • The point is that you use *the same* `pty`-enabled code for the good, the bad and the ugly commands. I've provided the links to several working code examples that show how to read output from a subprocess that might change its behavior if its standard streams are connected to a terminal. If you run any of the examples and they fail then leave the comment under the corresponding answer. – jfs May 09 '15 at 15:50
  • .. or update your question with [the minimal complete code example](http://stackoverflow.com/help/mcve) that shows the issue (mention what do you expect to happen and what happens instead). – jfs May 09 '15 at 15:55
  • what issues do you have with running `pexpect.runu('vim', timeout=3, withexitstatus=1)`? – jfs May 09 '15 at 16:32
  • @J.F.Sebastian Thanks a lot. I've figured out how to feed pty to subprocess. It turns out that in `vim` cases, it is stdin I need to take care of, not stdout or stderr. I was so dumb, sorry... – Zhuoyun Wei May 09 '15 at 16:39
  • `pty`-related issues are poorely documented. Note: [the first subprocess example has `stdin=slave_fd` while the 2nd (older -- don't use) hasn't](http://stackoverflow.com/a/12471855/4279). Unrelated: to enable timeout, you could use `while select([master_fd], [], [], timeout)[0]:` instead of `while 1:` in the 1st example. – jfs May 09 '15 at 16:45

4 Answers4

3

What you expect is a function that receives command as input, and returns meaningful output by running the command.

Since the command is arbitrary, requirement for tty is just one of many bad cases may happen (other includes running a infinite loop), your function should only concern about its running period, in other words, a command is “bad” or not should be determined by if it ends in a limited time or not, and since subprocess is asynchronous by nature, you can just run the command and handle it in a higher vision.

Demo code to play, you can change the cmd value to see how it performs differently:

#!/usr/bin/env python
# coding: utf-8

import time
import subprocess
from subprocess import PIPE


#cmd = ['ls']
#cmd = ['sleep', '3']
cmd = ['vim', '-u', '/dev/null']

print 'call cmd'
p = subprocess.Popen(cmd, shell=True,
                     stdin=PIPE, stderr=PIPE, stdout=PIPE)
print 'called', p

time_limit = 2
timer = 0
time_gap = 0.2

ended = False
while True:
    time.sleep(time_gap)

    returncode = p.poll()
    print 'process status', returncode

    timer += time_gap
    if timer >= time_limit:
        print 'timeout, kill process'
        p.kill()
        break

    if returncode is not None:
        ended = True
        break

if ended:
    print 'process ended by', returncode

    print 'read'
    out, err = p.communicate()
    print 'out', repr(out)
    print 'error', repr(err)
else:
    print 'process failed'

Three points are notable in the above code:

  1. We use Popen instead of check_output to run the command, unlike check_output which will wait for the process to end, Popen returns immediately, thus we can do further things to control the process.

  2. We implement a timer to check for the process's status, if it runs for too long, we killed it manually because we think a process is not meaningful if it could not end in a limited time. In this way your original problem will be solved, as vim will never end and it will definitely being killed as an “unmeaningful” command.

  3. After the timer helps us filter out bad commands, we can get stdout and stderr of the command by calling communicate method of the Popen object, after that its your choice to determine what to return to the user.

Conclusion

tty simulation is not needed, we should run the subprocess asynchronously, then control it by a timer to determine whether it should be killed or not, for those ended normally, its safe and easy to get the output.

Reorx
  • 2,801
  • 2
  • 24
  • 29
  • Nice demonstration! I did not realize `check_output` is synchronous while underlying `Popen` is asynchronous. I'll accept this as the answer. Thanks! – Zhuoyun Wei May 10 '15 at 11:09
1

Well, SSH is already a tool that will allow users to remotely execute commands and be authenticated at the same time. The authentication piece is extremely tricky, please be aware that building the software you're describing is a bit risky from a security perspective.

There isn't a way to determine whether a process is going to need a tty or not. And there's no os.isatty method because if you ran a sub-processes that needed one wouldn't mean that there was one. :)

In general, it would probably be safer from a security perspective and also a solution to this problem if you were to consider a white list of commands. You could choose that white list to avoid things that would need a tty, because I don't think you'll easily get around this.

FrobberOfBits
  • 17,634
  • 4
  • 52
  • 86
  • OpenSSH or other interactive remote shell is not an option for various reasons in my case. The robot is XMPP-based. It is designed to receive commands in chat messages, execute them, and send back their output. – Zhuoyun Wei May 09 '15 at 14:58
  • Also, whitelists with `Shell=False` is not an option, since pipes, redirections and other shell functions are not available. It is a headache for me to reconstruct pipes for received commands. – Zhuoyun Wei May 09 '15 at 15:08
0

Thanks a lot for @J.F. Sebastia's help (see comments under the question), I've found a solution (workaround?) for my case.

The reason why vim breaks terminal while ls does not, is that vim needs a tty. As Sebastia says, we can feed vim with a pty using pty.openpty(). Feeding a pty gurantees the command will not break terminal, and we can add a timout to auto-kill such processes. Here is (dirty) working example:

#!/usr/bin/env python3

import pty
from subprocess import STDOUT, check_output, TimeoutExpired

master_fd, slave_fd = pty.openpty()


try:
    output1 = check_output(['ls', '/'], stdin=slave_fd, stderr=STDOUT, universal_newlines=True, timeout=3)
    print(output1)
except TimeoutExpired:
    print('Timed out')

try:
    output2 = check_output(['vim'], stdin=slave_fd, stderr=STDOUT, universal_newlines=True, timeout=3)
    print(output2)
except TimeoutExpired:
    print('Timed out')

Note it is stdin that we need to take care of, not stdout or stderr.

Zhuoyun Wei
  • 746
  • 1
  • 7
  • 14
  • `timeout` for subprocess is a new feature in Python 3, for better compatibility you can implement a simple timer yourself. – Reorx May 09 '15 at 17:43
  • Use pypi.python.org/pypi/subprocess32 for subprocess timeouts for Python2.x. It is a backport of Python 3.x – qneill Nov 28 '16 at 21:22
0

You can refer to my answer in: https://stackoverflow.com/a/43012138/3555925, which use pseudo-terminal to make stdout no-blocking, and use select in handle stdin/stdout.

I can just modify the command var to 'vim'. And the script is working fine.

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import os
import sys
import select
import termios
import tty
import pty
from subprocess import Popen

command = 'vim'

# save original tty setting then set it to raw mode
old_tty = termios.tcgetattr(sys.stdin)
tty.setraw(sys.stdin.fileno())

# open pseudo-terminal to interact with subprocess
master_fd, slave_fd = pty.openpty()

# use os.setsid() process the leader of a new session, or bash job control will not be enabled
p = Popen(command,
          preexec_fn=os.setsid,
          stdin=slave_fd,
          stdout=slave_fd,
          stderr=slave_fd,
          universal_newlines=True)

while p.poll() is None:
    r, w, e = select.select([sys.stdin, master_fd], [], [])
    if sys.stdin in r:
        d = os.read(sys.stdin.fileno(), 10240)
        os.write(master_fd, d)
    elif master_fd in r:
        o = os.read(master_fd, 10240)
        if o:
            os.write(sys.stdout.fileno(), o)

# restore tty settings back
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
Community
  • 1
  • 1
Paco
  • 411
  • 3
  • 9