Shell level access to a Linux host enables a rich universe of possibilities, even if (initially) lacking administrative privileges. If you find yourself in the situation that you have to provide access to certain applications or functions to someone who you do not trust on a moral or technical basis, a custom login shell might be one possible solution.
A Short Look at the Alternatives
It is not always worthwhile writing a custom web interface to the desired functionality, and it can be quite a challenge to secure this avenue with proper encryption, authentication and authorization. If the latter does not look like a problem at first, think about man-in-the-browser attacks.
Some might be tempted to leverage SSH's protection and secure authentication/authorization by then granting access to the selected resources via sudo
, but that gives the user much more power on the target system than actually needed; take fork-bombs as one possible example of a class of attack vectors. Restricting shell access by using the corresponding feature (e.g. bash -r
) does not gain much on this front as it is easily circumvented or otherwise ineffective.
Example Use-Case
Let me demonstrate the principle using a simple example taken from my real-world experience: a system operator needs to be able to start and stop a process on a remote host and query its status. The login authentication could f.e. be managed centrally using LDAP and/or Kerberos, or it could be connected to a cryptographic token with multi-factor security: the choice is not limited by the login shell approach.
Implementation
The Core: Method Dispatch
The core functionality of a shell consists in parsing the commands entered by the user and invoking the corresponding functions. The following puts focus on simple and compact configuration and extensibility:
my $dispatch = {
"prog" => {
"status" => \&fn_prog_status,
"start" => \&fn_prog_start,
"stop" => \&fn_prog_stop,
"log" => \&fn_prog_log},
"help" => \&fn_help,
"quit" => \&fn_quit,
};
sub dispatch {
my ($prefix, $remainder, $table) = @_;
die "missing token after '@$prefix'\n" unless $#$remainder >= 0;
my $nextword = shift @$remainder;
my $nextref = ${$table}{$nextword} or
die "unrecognized token after '@$prefix': '$nextword'\n";
push @$prefix, $nextword;
if (ref($nextref) eq 'CODE') {
&$nextref($prefix, @$remainder);
} else {
&dispatch($prefix, $remainder, $nextref);
}
}
while (<>) {
my @tokens = split;
&dispatch([], \@tokens, $dispatch);
}
The dispatch
function takes three references as arguments: the list of tokens which have already been recognized, the list of remaining tokens and finally the hash table to be used for the next token. The data structure which defines the grammar for the command line language is a tree of nested hash tables, $dispatch
. The leaves of the tree are CODE-references, which are executed when the dispatcher reaches them. The dispatch algorithm works its way recursively into the nested hash tables, at each step transferring one token, $nextword
, from the head of the @$remainder
to the tail of the @$prefix
. This token is looked up in the current hash table, and if found indicates either that more tokens are required (line 21) or that the search for the function to be executed is completed. A reference to the @$prefix
together with the list of remaining tokens is passed to the function as arguments.
Lines 25–28 show exemplary how the dispatch might be invoked, but more on that later.
Providing the Functionality
Starting, stopping and checking on processes is quite easy with Perl, thus providing the functionality behind the commands is a breeze.
sub fn_prog_status {
my @pid = `pgrep prog.bin`;
chomp @pid;
if (@pid) {
print "PROG running with PID @pid\n";
&connstat(@pid);
} else {
print "PROG not running\n";
}
}
This small function uses the pgrep
command line tool to find the process IDs of running instances of the program. The &connstat
function will be the topic of another article.
sub fn_prog_start {
chdir '/path/to/prog' or die "cannot chdir to /path/to/prog: $!\n";
my $pid = fork;
unless ($pid) {
setsid;
open STDOUT, '>prog.log' or die "cannot redirect STDOUT: $!\n";
open STDERR, '>&STDOUT' or die "cannot redirect STDERR: $!\n";
open STDIN, '</dev/null' or die "cannot redirect STDIN: $!\n";
exec { '/path/to/prog/prog.bin' } 'prog.bin', 'arguments'
or die "cannot execute prog: $!\n";
}
print "PROG started with PID $pid\n";
}
Starting a process is nearly as easy: change to the desired working directory, fork a new process, create a new session and process group with the help of POSIX::setsid
(thus losing the controlling TTY), redirect standard I/O channels as needed and execute the program image. Using the indirect argument variant of exec
protects against any possible shell interpretation of the program argument list.
sub fn_prog_stop {
print "Do you really want to stop PROG? ";
exit 1 unless <STDIN> =~ /^y(es)?$/;
&killproc('prog.bin', 'INT') && print "PROG stopped\n" or
&killproc('prog.bin', 'TERM') && print "PROG terminated\n" or
&killproc('prog.bin', 'KILL') && print "PROG killed\n" or
print "cannot kill PROG, aborting\n";
}
sub killproc {
my ($name, $signal, $tries) = @_;
$tries = 5 unless defined $tries;
print "sending SIG$signal to $name\n";
system { '/usr/bin/pkill' } 'pkill', "-$signal", '-x', $name;
while ($tries > 0) {
print ".";
last if `pgrep $name` eq '';
$tries--;
sleep 1;
}
print "\n";
$tries;
}
Stopping a process reliably is slightly more work: first, the user is asked to confirm this action in line 2 and the action is aborted unless the answer is either "y"
or "yes"
(the watchful reader might ask himself about the effect of the exit
on the calling context; this question will be answered in due time). The processes named "prog.bin" are then sent signals of increasing urgency, waiting 5 seconds for each to manifest its effect. If nothing helps (SIGKILL cannot be caught, so something must be really wrong in this case), print a message and give up. The signaling itself is again outsourced to a program from the "procps" package: pkill
. Its -x
flag is useful to match the whole process name to the pattern. The truly paranoid would probably escape the '.' in "prog.bin" with a backslash to be extra sure not to kill processes like "prog5bin".
sub fn_prog_log {
chdir '/path/to/prog' or die "cannot chdir to /path/to/prog: $!\n";
exec { '/bin/cat' } 'cat', 'prog.log' or die "cannot execute cat: $!\n";
}
Showing the logfile is the most trivial operation. This could of course be change to something more elaborate, like using a pager or adding a tail -f
mode.
Adding Convenience: Term::Readline
The code up to now, if simply put together into one script, would result in a rather spartanic user interface. Adding a prompt, command line editing and history is easy to do and presents a huge improvement in usability.
my $term = new Term::ReadLine 'PROG Server Interface';
$SIG{'INT'} = 'IGNORE';
while (defined ($_ = $term->readline('PROGSH> '))) {
last if /^\s*quit\s*$/;
my $pid = fork;
unless ($pid) {
$SIG{'INT'} = 'DEFAULT';
my @p = split;
&dispatch([], \@p, $dispatch);
exit 0;
}
waitpid $pid, 0;
WIFSIGNALED($?) and
print "\nprevious command died with signal ".WTERMSIG($?)."\n";
WIFEXITED($?) && WEXITSTATUS($?) and
print "\nprevious command exited with return code ".WEXITSTATUS($?)."\n";
print "\n";
}
The core loop is replaced by an iteration over the result of $term->readline
, which also displays a nice prompt. If Term::Readline::Gnu
is installed, also command line editing and a history buffer are available.
For each command entered by the user, a new subprocess is forked in which the dispatch function is invoked; thus, the functions providing the actual functionality do not have to care about isolating state between invocations, and subprocesses started from functions like &fn_prog_start
are automatically reparented to the init-process and deliver their death screams to the "global child-reaper". In the style of a normal shell, SIGINT is ignored by the parent process and re-enabled when executing commands.
Adding the Cream Topping: Online Help and Tab-Completion
The dispatch tree introduced above contains all the information needed to provide even more convenience. The only thing needed is the recursive treatment:
sub fn_help {
print join "\n", &help([], $dispatch);
}
sub help {
my ($prefix, $table) = @_;
if (ref($table) eq 'CODE') {
"@$prefix";
} else {
map { &help([@$prefix, $_], $$table{$_}) } sort keys %$table;
}
}
The help function iterates over the dispatch table in the same fashion as the dispatch function and prints the path to each leaf—aka CODE reference—as space separated list (check the documentation for $"). Adding actual help text to the listing of known commands would entail storing this information in the dispatch tree (e.g. changing the leaves into array references which contain the CODE reference plus any associated information) and printing it in line 8; I leave this as an exercise to the devoted reader.
sub complete {
my ($remainder, $table) = @_;
my $nextword = shift @$remainder;
$nextword = '' unless defined $nextword;
my $nextref = $table->{$nextword};
return @$remainder ? undef : grep { /^\Q$nextword/ } sort keys %$table
unless defined $nextref;
return $nextword
unless @$remainder;
return undef
if ref($nextref) eq 'CODE';
&complete($remainder, $nextref);
}
# [...]
my $term = new Term::ReadLine 'PROG Interface';
my $attribs = $term->Attribs;
$attribs->{completion_function} = sub { return &complete([split(' ', $_[1], -1)], $dispatch); };
Tab-completion is slightly more complicated, but that is inherent to the task: the user shall be given all possible commands starting with the text he just typed, and as we all know “the problem is choice” (Neo). Let me explain the different outcomes of a step in the recursive iteration:
[line 6]
If the word typed by the user is not found, return a—possibly empty—list of all keywords which start like the current token, unless more tokens have been entered, in which case return undef
to indicate failure. The \Q
flag in the regular expression protects against malicious input or user confusion by interpreting all characters from $nextword
literally.
[line 8]
If the list of remaining tokens is empty, return the current token. This happens if the user has typed a complete keyword without trailing whitespace, leading to a space being added by the ReadLine library.
[line 10]
If the current token corresponds to a leaf of the dispatch tree, disable further completion by returning undef
.
[line 12]
Otherwise continue with the next step of the recursion.
One comment on line 4: if the list of remaining tokens was empty upon entering the function, synthesize an empty token to make the lookup in line 5 happy and at the same time return all keywords unfiltered in line 6. This only happens when the user calls the completion without having typed anything.
Lines 17 and 18 need to be added to the initialization of Term::Readline
as shown. For the completion to work reliably, the split
function needs to be called with a third argument in order to retain trailing empty elements in the returned list, while using the special-cased single-space-string as split pattern suppresses leading empty elements.
A Word on Security
On the surface, the code presented up to now has no obvious security flaws. But what happens with the external processes which are spawned? A complete discussion of Perl security is outside the scope of this article, so let me mention a few guidelines:
- Sanitize
%ENV
, especially if AcceptEnv
or PermitUserEnvironment
are set in the SSHD configuration. The nastier elements of the environment include PATH
, IFS
, CDPATH
, ENV
, BASH_ENV
, LD_LIBRARY_PATH
, LD_PRELOAD
and friends.
- Do not use information from a network share within the programs controlled by the user unless that network share cannot be manipulated by the user. If you are forced to violate this rule, think very carefully about all the consequences.
- Avoid using locales within Perl. If you must, beware of the effects on your regular expressions (e.g.
\w
).
- Read
man perlsec
from time to time.
Closing Remarks
One thing I repeatedly notice: Perl provides exactly the right level of abstaction for a variety of tasks. The seamless integration of UNIX system calls with convenient data types and flexible control structures can be used to formulate the program logic in clear and compact terms.
For the interested reader, and without any warranty, a compilation of the above snippets into an executable Perl script can be downloaded here. It contains some changes in order to make the executed program's name and location configurable, so you should be able to try it out.