Secure Shell is a wonderful tool for automated or interactive pass wordless remote access, but it is not easy to get security right with this setup.
Out of the box you have two options: either you allow complete shell access to the remote system, or you restrict access to just one (1) specific command line.
Several proposals can be found on the Internet which show how to solve various different use cases of remote access where something in between these two extremes is required.
One popular use case is automated backups, mirror scripts - often involving rsync - or access to revision control systems and the like. Joey Hess’ article gives an overview and points out potentially insecure solutions.
Another use case is to allow (real) remote users to run one out of several possible commands, check out this bin blog post for a simple example. An interesting way to generalize this by use of a dedicated tools directory is described in this StackExchange article, all along with a security related discussion.
Let’s cover both classes of use cases with just one script which can serve any user account on the server.
In comes ‘only’.
The following sections will talk you through the perception and raise of ‘only’. If you don’t want to listen to the whole biography of ‘only’ just skip down to The Grown Up ‘only’.
The one and ‘only’
‘only’ is a shell script, which only allows to run some command, let’s see the embryonic ‘only’ script:
#!/bin/sh
allowed=$1
set -- $SSH_ORIGINAL_COMMAND
if [ "$1" = "$allowed" ]; then
exec "$@"
fi
echo you may only $allowed, denied: $@ >&2
exit 1`
We copy ‘only’ into the PATH of a remote host and put it in front of a
ssh key in the authorized_keys
file like this:
command="only ls" ssh-rsa AAAA...
Let’s see what this does when we access the remote host with, e.g.
ssh user@remote.host ls /bin
- ‘only’ is run with the following command line:
ls
- We save
$1
, the only allowed command in the environment variableallowed
- We set the command line to the originally sent command line making
use of ssh’s feature of setting it up in the environment variable
SSH_ORIGINAL_COMMAND
, it will be:ls /bin
in this case. - Now
$1
holds the first token of the original command line:ls
. $allowed
=$1
=ls
, they are the same, so we- execute the entire command line
"$@"
,ls /bin
in our case, which prints all files inside/bin
and exit.
You might want to review the sh(1) man page if these dollars, brackets and “$@” warts look strange to you. The “ssh” and “remote” talk can be enlightened by the sshd(8) manual page.
If we use any other command, like rm -rf /
we get:
only
is run with:ls
$allowed
=ls
,$1
=rm
, they are different so we- print a diagnostic message on stderr(3) and exit with a failure code of 1 so that the sending end gets notified.
Bingo: we allow to run the one and only explicitly allowed command,
but we now can give it any command line we want. This helps us with
the second use case, however is rather insecure. Consider running
rm -rf /
on a command="only rm" ...
setup in the root
account…
But let’s see into this later (security comes, ahem … late, right?)
Not just ‘only’ one
With some care, ‘only’ grows a little bit and is now able to handle more then one command:
#!/bin/sh
cmds="$@"
set -- $SSH_ORIGINAL_COMMAND
for allowed in $cmds; do
if [ "$allowed" = "$1" ]; then
exec "$@"
fi
done
echo you may only $cmds, denied: $@ >&2
exit 1
I allow here myself to mimic the example of the bin blog article. The
line in the authorized_keys
file now might look like this:
command="only ps vmstat cups"
Please still don’t try this at home, rather follow me, when I mentally run
ssh user@remote.host cups stop
- ‘only’ receives the following command line
ps vmstat cups
which we save incmds
. After theset --
, the command line becomescups stop
. - The
for
loop picksps
as the first value forallowed
fromcmds
. $allowed
=ps
,$1
iscups
, we fall through theif
and enter the next iteration offor
.$allowed
becomesvmstat
in the second, and thencups
in the last iteration offor
.cups
is equal to$1
, so we executecups stop
.
If we had sent the rm -rf /
command, we would have fallen through
the loop and printed denied
to the user.
Picky ‘only’
Youngsters are complicated. So is ‘only’. He grows up a little bit and start to refuse some of the command line arguments thrown at him, he just got picky.
If you are not into adolescent psychology I offer you once again to
skip to The Grown Up ‘only’ section. Go download the adult only
script and abuse his workers rights by making him work for you all day
long without paying him a dime. But maybe you are curious about his
teenager years. If so, then stay with me. I’ll put the young only
on the couch and analyze him as quick as possible.
First a small preamble. Unless we want to write a whole string
matching utility library in shell, using variable expansion with their
ugly ${17##*/}
and whatelse evil constructs, we can just get a
little help of a good old friend. I refer to the humble and
ubiquitous sed(1) command.
Note that this means introducing (shock) regular expressions (huhh!). Yes, I also would prefer some simple glob(3) like pattern matching as done by sudo(1) in these cases, but this would involve some exotic character like Tcl(n) or friends, and those guys and girls are not always handily available. So let’s make these regex(7)es as painless as possible.
We just want to know, if some line of text matches, or not, a specific
pattern. sed(1) is quite reserved if we tell him to via the -n
flag, he will just swallow all input he can get, just like PAC-MAN,
and only talk back if we tell him so with a p
rint command. To
match, for example, the line ps
we’d use the following rule:
\:^ps$:p
. The \:
.. :
construct delimits the matching pattern,
^
is the start, $
the end of the line and in between the string we
are looking for: ps
. If sed(1) -n
receives any line of text he
will remain quiet, unless the line is exactly ps
, in which case he
mutters back ps
, via the trailing p
command. So matching results
in an echo of the input, not matching in remaining silent.
We’ll put the rules to filter the allowed command line in a file in
the remote users home directory and call it ~/.onlyrules
. So you
can set up ‘only’ for any number of users and adapt allowed commands
and rules individually, no need to change ‘only’ at all. Nice, isn’t
it?
To continue with our example we stuff the following lines into
~/.onlyrules
:
\:^ps$:p
\:^vmstat$:p
\:^cups \(stop\|start\)$:p
The last line is somewhat sophisticated: it allows ether ‘stop’ or |
‘start’ as arguments. The alternatives are bound together by the ()
parenthesis, our ornamentalophile sed(1) wants them all to be
prefixed with a \
backslash.
And here is our ‘only’ youngster.
#!/bin/sh
cmds="$@"
set -- $SSH_ORIGINAL_COMMAND
for allowed in $cmds; do
if [ "$allowed" = "$1" ]; then
if [ -z "$(echo $@ | sed -nf ~/.onlyrules)" ]; then
break
fi
exec "$@"
fi
done
echo you may only $cmds, denied: $@ >&2
exit 1
You already see the pimples, the attitude?!
When ‘only’ sees an allowed command, let’s say cups
, it shouts the
whole command line over to sed(1). sed(1) takes line by line of
~/.onlyrules
, compares it with the input and shouts it back on
stdout(3) if it matches. Consider the last run of the for
loop
(with cups
as $allowed
command). Suppose we sent cups stop
to
the remote host.
cups
is allowed, so we hand the command line over to sed(1).cups stop
matches the last line in~/.onlyrules
, so the output of the$()
command substitution iscups stop
, which is not a zero length string (-z
). Thus we skip thebreak
and- we
exec
the command line. Done!
Now we test the other way round. Let’s run cups status
:
cups
is allowed.cups status
does not match any line in~/.onlyrules
, sed(1) does not shout anything back to us and the command substitution is the zero length string""
.break
breaks out of thefor
loop and- we deny the command.
So, ‘only’ can do now everything that was promised in the introduction, everything?:
- We can lock down a remote account to one command, but with (controlled) arbitrary arguments.
- We can enable a set of allowed commands for a remote account.
- We can adapt the behavior for any number of remote accounts, without changing the ‘only’ script.
‘only’, however, is still a very fragile teenager, don’t entrust him your servers, yet, better wait for him to mature.
Why not this cute simple ‘only’?!
Verbosity
Although it is perfectly understandable that any unacquainted user wants to know why o dear was my command not accepted by the remote host, we wouldn’t want to give too many hints to the unacquainted attacker who is just trying to get into this remote server by means of a stolen ssh key and eager for any useful information. Especially in non interactive scenarios we’d just leave him wondering why.
The Grown Up ‘only’ allows you to tune him from complete silence up to idiotic verbosity towards the invoking user.
Accountability
Until now nobody notices when, what and for whom ‘only’ is working. You’ll just get a short log message from ssh itself, informing that somebody connected via ssh(1) to a specific account on your remote host.
The Grown Up ‘only’ uses logger(1) to tell us what command line
has been run by which user at the auth.info
level, and what
command line has been denied for which user at the auth.warn
level, so we can sort things out while struggling to forge these rules
and after that, in production use, find abusers.
Absolute command paths
If a user, human or automated, e.g. sends /usr/bin/vmstat
instead of
vmstat
to the remote host, we still would like the command to be
executed even if we only allowed vmstat
. Our stubborn teenager
‘only’ would reject this command because of his simplistic equality
match.
The Grown Up ‘only’ has gained some tolerance with his peers
already. He patiently looks up it’s allowed command in the users
PATH
and compares the result with the given command in case the
latter comes in with an absolute path. Thus only commands inside
the users PATH
are allowed. If you want to lock down commands to a
specific directory put it as the only directory into the PATH
environment variable for this user (sshd(8) can help you with this).
Then set LOGGER
, WHICH
and SED
at the top of the ‘only’ script
to the respective programs full path specification on your remote
host.
You can also allow commands outside of PATH
, by specifying them
with an absolute path in the authorized_keys
file. In this case,
The Grown Up ‘only’ requires an exact match with the sent command,
but does not enforce it to be in the PATH
.
Smarter command line matching
Since commands can come in either with absolute or relative paths, the rules for the command line filter would have to take this into account and would become complicated, difficult to read and therefore error prone.
To make it easier to write command line filters, the lines sent to
sed(1) are instead composed of the $allowed
command in question
followed by the command line arguments (stripping off the actually
send notation of the command). Recurring to the previous example, the
lines sent to sed(1) in the second iteration of the for
loop would
be: vmstat
and not /usr/bin/vmstat
and thus will match with
The Grown Up ‘only’ but not with Picky ‘only’.
Quoting hell
When starting to grow ‘only’ I used the youngster to restrict access
to a darcs repository, only to find out, that darcs sends the
repository directory path single quoted ''
when creating the
repository but without quotes when getting it. With exec "$@"
the
repository directory repo
gets created as 'repo'
on the remote
host, and thus becomes inaccessible to the other darcs commands, which
naturally expect it to be repo
.
The Grown Up ‘only’ therefore does eval "$@"
, which parses away
the quotes.
Note however, that I fear that other command line constructs now might horribly fail or disaster be injected into your server by evil forces finding out how to take advantage of quoting hell.
The Grown Up ‘only’
Installation and basic configuration
Please download the only script and the example rules file. Both start with an explanation on how to use and configure them, please read these comments in place of a manual. An example ~/.onlyrc is available too.
You can also get ‘only’ from my public darcs repository. You don’t need darcs(1) for this, just wget(1), curl(1) or your browser.
1. Put the ‘only’ script into a location accessible to all users on the
remote host, e.g. into /usr/local/bin
.
2. Create a ssh key pair. For a starter, the following command line
will create the files only_key
and only_key.pub
without a
pass phrase for you.
ssh-keygen -P "" -f only_key
Copy
only_key.pub
toauthorized_keys
, and prefix:command="only ls grep who",no-agent-forwarding,no-port-forwarding,no-pty,no-user-rc,no-X11-forwarding
to the first and only line, leaving a space before the
ssh-rsa AAAA...
part. Of course, instead ofls grep who
, you’ll put in the command(s) you want to allow on the remote host.Install this
authorized_keys
file in the.ssh
sub directory of the user accounts home directory on the remote host which should run the respective commands. You might want to deny the user account write permissions on the file.Copy
.onlyrules
into the home directory of the same user account on the remote host and adapt to your needs. See Writing ‘only’ rules for some tips.You are done with this user account. Repeat, starting from “2. Create a ssh key pair.” as often as needed for this remote host.
You are done with this remote host. Repeat from “1. Put the ‘only’ script” for any further remote host you want to access.
Writing ‘only’ rules
Always consider locking down the command line to only match precisely
the wanted alternatives. While options will likely come in a fixed
set and variation, the arguments like file paths or user names might
vary considerably and unforeseeable. For paths you might consider
require a given prefix and disallow dot-dot ..
so attackers or mad
gone scripts can’t break out of their allowed realm.
You surely have rules how a user name may be constructed on your remote host: minimum/maximum length and a set of allowed characters come to mind. Create the respective sed(1) rules for these, check that they don’t allow white space or comment and escape characters in between.
Always filter on the whole command line, that is, make the filter have
a ^
at the start and a $
at the end, else anybody can prefix or
annex arbitrary strings and thus circumventing your allowed command
list.
All that said, you might not know all possible variants of the
invocation of a command in advance and/or are too lazy to figure it
out beforehand. Shame on you, but anyway… lock down the command
(let’s name it new_kid
for just another example), then start with a
completely open filter like this:
\:^new_kid:{p;q}
You note the missing $
at the end of the command string, do you?.
Now run all variants of new_kid
s invocations you can imagine, or
gather them after one day or so running and get the results out of
syslog(3).
Let’s say that new_kid
gives as allowed command lines like the
following:
new_kid --server -P ./
new_kid --cleanup -P ./
new_kid --stats -P ./ /var/log/new_kid.log
new_kid --discard /var/log/new_kid.log.10
new_kid --rotate /var/log/new_kid.log.9
...
new_kid --rotate --/var/log/new_kid.log
Then we could consider a filter like:
\:new_kid --\(server\|cleanup\|stats\) -P \(/var/log/new_kid.log\)\?\./$:{p;q}
\:new_kid --\(rotate\|discard\) /var/log/new_kid\.log\(\.[1-9]0?\)\?$:{p;q}
or just stop that regex(7) head pain and pack all found lines
literally between \:^
and $:{p;q}
and you are done.
If you did not get all possible invocations in the first run, you will
get the others as denied
in your logs. Watch out for lost ones
after a month or so and then after a year. (Just kidding, you know
your monthly and yearly scripts well, don’t you?).
Finally note that shell magic is helping you when writing filters, since white space between arguments is reduced to exactly one space.
Substitution rules
Attentive readers have already noticed that our Picky ‘only’ is not
an equivalent replacement for the example in the bin blog post.
If, for example, we send cups start
to the remote server, the
command /etc/init.d/cupsys start
should be executed instead.
Well, while I find this startling, from a security point of view, I needed to support this capability to hold my word on the claim in the introduction.
Create a file ~/.onlyrc
in the home directory of the user running
‘only’ and write enable_command_line_substitution
on a line by
itself. From this moment on, ‘only’ will substitute the original
command it got sent to with the string printed out by sed(1). In the
rules file substitute \:^cups \(stop\|start\)$:p
with the following
monster:
\:^cups \(stop\|start\)$:{
s:^cups \(.*\):/etc/init.d/cupsys \1:p
q
}
sed(1)s s
ubstitute command will replace cups
with
/etc/init.d/cupsys
and the \1
place holder with whatever command
line option (of ‘stop’ or ‘start’) it encounters within the \(.*\)
parentheses.
This is a contrived example. The bin blog example replaces ps
with
ps -ef
. We don’t need s
ubstitute here, instead we:
\:^ps$:{
c\
ps -ef
q
}
Well, this looks ugly. But hey! The c\
command puts out the
subsequent line, which is: ps -ef
and omits the input completely.
We must put the q
command on its proper line so it gets not appended
too. c\
allows us to write long complicated command lines easily.
Look! You can go wild on sed(1) and invent your super-hyper-uber substitutions sed(1) programs if you want to, people to even math with it! But once again: don’t do command line substitutions for your own mental health and your servers integrity’s sake.
Verbose feedback
By default ‘only’ just exits with a failure code if a command is not allowed to run.
The ~/.onlyrc
file can be used to make ‘only’ chatty about denied
commands. You can:
- Show an enigmatic
denied
to the user withshow_terse_denied
. - Show the allowed command to the user with
show_allowed
. - Show the exact denied command line with
show_denied
. - Print out a complete manual by appending text in the
~/.onlyrc
file afterhelp_text
.
The provided example ~/.onlyrc
file illustrates and documents all of
these options. If you want to mimic the bin blog example your
~/.onlyrc
would look like:
show_allowed
help_text
Sorry. Only these commands are available to you.
Security considerations
As I told you before, security comes late, right? Now, a serious review on security related topics is way out of the scope of this article. I just want to throw in two thoughts, or three, or four…
Any command which just reads from the remote system (files, process listings, kernel or interface stats, etc.) can be abused to gain insight into the system (for later hacking it) or to obtain information which might be private (user data, like credit card numbers or passwords, or habits like login statistics, emails, …). One of the objectives of running commands via ssh with public/private key authentication is to restrict them to user accounts which don’t have excessive rights for obtaining information. ‘only’ can help you lock down this further, but do your homework on securing the remote host first.
Commands which can write to the remote system or modify any other of its resources (processes, kernel variables, interface settings, etc.) are even more sensible. Let’s start with the possibility of overwriting the settings for ‘only’ which can be used to gain unrestricted access to the system. But the same principle as before applies: if the user account is already ‘secure’, an attacker cannot go much further.
Additionally consider resource depletion. Although you can craft denial of service with a read only access, with “write” access come additional risks into play. Don’t allow the user to allocate a lot of processes or disk space, as this can be abused for writing oodles of senseless data to fill up your disk just to annoy you, or better for storing images and videos with disputable content, for gratuitous distribution from your server. This is where quotas and limits come in, start with quota(1) and prlimit(1) if you are on Linux and want to go into further detail.
Note that a lock down script like ‘only’ is just one concept for ssh
security. You might get yourself a restricted shell and cast that
upon the remote user, like indicated in this article for rsync. A
funny article by Doug Stilwell does not inspire confidence into
the security of restricted rbash
though. Similar consideration like
in this article might apply to other restricted shells, and of course
they do for ‘only’.
Postamble
Why did I perceive ‘only’? I wanted to set up an unprivileged account for managing a private darcs(1) repository for distributing configuration data of my servers. It did not seem right to me to allow complete shell access for this, so I soon stumbled upon the issue of the inflexible forced command in ssh. When I came up with the embryonic ‘only’ approach I started to look around on the Internet and saw that it has not been proposed yet in this form. With a sudo(1) background of pattern matching on allowed command lines I started to go for the picky ‘only’. Writing this article whetted my appetite and while I don’t think (ab)using ‘only’ for interactive session lock down ever, I implemented all the related surplus.
Playing around with the different aged ‘only’s soon brought me to test driven design. So there are (primitive) test suites available. This is another interesting area, but also quite another story.
Credits for ‘only’ go to all inspiring inputs, some of them referred to by the external links.
Please give me feedback if you encounter any bug or issue with ‘only’. I am especially interested in comments with respect to ‘only’s (lack of) security.
Building small SSL libraries is a challenge. This series of articles follow up on how to build SSL libraries with diet libc at different points in time.
We use this to compile gatling with https support. The respective recipe is also included in this post.
Preconditions
- This is local software
- which needs diet libc.
- Optionally gatling, if you want the web server.
Building mbedtls with dietlibc
cd ~/progs
curl -O https://tls.mbed.org/download/start/mbedtls-2.7.0-apache.tgz
tar xzf ~/mbedtls-2.7.0-apache.tgz
cd mbedtls-2.7.0
make CC="diet -Os gcc -pipe -nostdinc" DESTDIR=/opt/diet install
Building openssl with dietlibc
cd ~/progs
curl -O https://www.openssl.org/source/openssl-1.1.0g.tar.gz
cd openssl-1.1.0g
./config no-dso no-shared no-engine -L/opt/diet/lib-i386 --prefix=/opt/diet -lpthread
make CC="diet -Os gcc -pipe -nostdinc" install_sw
Building gatling with https support
gatling with mbedtls 2.7.0
You might want to make clean
before.
make LDFLAGS="-L../libowfat -L/opt/diet/lib" LDLIBS="-lmbedx509 -lmbedcrypto -lpthread -lowfat -lz `cat libsocket libiconv libcrypt`" ptlsgatling
make install
gatling with openssl-1.1.0g
Apply the following patch to gatlings ssl.c file.
Index: ssl.c
===================================================================
RCS file: /cvs/gatling/ssl.c,v
retrieving revision 1.26
diff -u -r1.26 ssl.c
--- ssl.c 1 Feb 2018 02:06:18 -0000 1.26
+++ ssl.c 28 Mar 2018 16:28:18 -0000
@@ -8,6 +8,7 @@
#include <fcntl.h>
#include <openssl/ssl.h>
#include <openssl/engine.h>
+#include <openssl/err.h>
#include <ctype.h>
#include <sys/time.h>
#include <sys/stat.h>
@@ -325,7 +326,7 @@
if (!library_inited) {
library_inited=1;
SSL_library_init();
- ENGINE_load_builtin_engines();
+// ENGINE_load_builtin_engines();
SSL_load_error_strings();
}
if (!(ctx=SSL_CTX_new(SSLv23_client_method()))) return -1;
@@ -354,7 +355,7 @@
void free_tls_memory() {
SSL_CTX_free(ctx);
- ENGINE_cleanup();
+// ENGINE_cleanup();
CRYPTO_cleanup_all_ex_data();
ASN1_STRING_TABLE_cleanup();
ERR_free_strings();
You might want to make clean
before.
make CFLAGS=-I/opt/diet/include gatling
make CFLAGS=-I/opt/diet/include LDFLAGS="-L/opt/diet/lib-x86_64 -L/opt/diet/lib" LDLIBS="-lpthread -lowfat" tlsgatling
make install
Results
$ ls -1hs /opt/diet/bin/*gatling
204K /opt/diet/bin/gatling
704K /opt/diet/bin/ptlsgatling
2.4M /opt/diet/bin/tlsgatling