When a process executes a command (via the execve()
system call), its memory is wiped. To pass some information across the execution, the execve()
system calls takes two arguments for that: the argv[]
and envp[]
arrays.
Those are two arrays of strings:
argv[]
contains the arguments
envp[]
contains the environment variable definitions as strings in the var=value
format (by convention).
When you do:
export SECRET=value; cmd "$SECRET"
(here added the missing quotes around the parameter expansion).
You're executing cmd
with the secret (value
) passed both in argv[]
and envp[]
. argv[]
will be ["cmd", "value"]
and envp[]
something like [..., "PATH=/bin:...", "HOME=...", ..., "SECRET=value", "TERM=xterm", ...]
. As cmd
is not doing any getenv("SECRET")
or equivalent to retrieve the value of the secret from that SECRET
environment variable, putting it in the environment is not useful.
argv[]
is public knowledge. It shows in the output of ps
. envp[]
nowadays is not. On Linux, it shows in /proc/pid/environ
. It shows in the output of ps ewww
on BSDs (and with procps-ng's ps
on Linux), but only to processes running with the same effective uid (and with more restrictions for setuid/setgid executables). It may show in some audit logs, but those audit logs should only be accessible by administrators.
In short the environ that is passed to an executable is meant to be private or at least about as private as the internal memory of a process (which under some circumstances an other process with the right privileges can also access with a debugger for instance and can also be dumped to disk).
Since argv[]
is public knowledge, a command that expects data meant to be secret on its command line is broken by design.
Usually, commands that need to be given a secret, provides you with another interface for doing so, like via an environment variable. For instance:
IPMI_PASSWORD=secret ipmitool -I lan -U admin...
Or via a dedicated file descriptor like stdin:
echo secret | openssl rsa -passin stdin ...
(echo
being builtin, it doesn't show in the output of ps
)
Or a file, like the .netrc
for ftp
and a few other commands or
mysql --defaults-extra-file=/some/file/with/password ....
Some applications like curl
(and that's also the approach taken by @meuh here) try to hide the password that they received in argv[]
from prying eyes (on some systems by overwriting the portion of memory where the argv[]
strings were stored). But that's not really helping and gives a false promise of security. That leaves a window in between the execve()
and the overwriting where ps
will still show the secret.
For instance, if an attacker knows that you're running a script doing a curl -u user:somesecret https://...
(for instance in a cron job), all he has to do is evict from the cache the (many) libraries that curl
uses (for instance by running a sh -c 'a=a;while :; do a=$a$a;done'
) so as to slow down its startup, and even doing a very inefficient until grep 'curl.*[-]u' /proc/*/cmdline; do :; done
is enough to catch that password in my tests.
If the arguments is the only way you can pass the secret to the commands, there may still be some things you could try.
On some systems, including older versions of Linux, only the first few bytes (4096 on Linux 4.1 and before) of the strings in argv[]
can be queried.
There, you could do:
(exec -a "$(printf %-4096s cmd)" cmd "$secret")
And the secret would be hidden because it's past the first 4096 bytes. Now people who have used that method must regret it now since Linux since 4.2 no longer truncates the list of args in /proc/pid/cmdline
. Also note that it's not because ps
won't show more than so-many-bytes of a command line (like on FreeBSD where it seems to be limited to 2048) that one can't use to same API ps
uses to get more. That approach is valid however on systems where ps
is the only way for a regular user to retrieve that information (like when the API is privileged and ps
is setgid or setuid in order to use it), but is still potentially not future-proof there.
Another approach would be to not pass the secret in argv[]
but inject code into the program (using gdb
or a $LD_PRELOAD
hack) before its main()
is started that inserts the secret into the argv[]
received from execve()
.
With LD_PRELOAD
, for non-setuid/setgid dynamically linked executables on a GNU system:
/*
* replace ***** with secret read from fd 9
* gcc -Wall -fpic -shared -o inject_secret.so inject_secret.c -ldl
* LD_PRELOAD=/.../inject_secret.so cmd -p '*****' 9<<< secret
*/
#define _GNU_SOURCE
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <dlfcn.h>
#define PLACEHOLDER "*****"
static char secret[1024];
int __libc_start_main(int (*main) (int, char**, char**),
int argc,
char **argv,
void (*init) (void),
void (*fini)(void),
void (*rtld_fini)(void),
void (*stack_end)){
static int (*real_libc_start_main)() = NULL;
int n;
if (!real_libc_start_main) {
real_libc_start_main = dlsym(RTLD_NEXT, "__libc_start_main");
if (!real_libc_start_main) abort();
}
n = read(9, secret, sizeof(secret));
if (n > 0) {
int i;
if (secret[n - 1] == '\n') secret[--n] = '\0';
for (i = 1; i < argc; i++)
if (strcmp(argv[i], PLACEHOLDER) == 0)
argv[i] = secret;
}
return real_libc_start_main(main, argc, argv, init, fini,
rtld_fini, stack_end);
}
Then:
$ gcc -Wall -fpic -shared -o inject_secret.so inject_secret.c -ldl
$ LD_PRELOAD=$PWD/inject_secret.so ps '*****' 9<<< "-opid,args"
PID COMMAND
7659 /bin/zsh
8828 ps *****
At no point would ps
have shown the ps -opid,args
there (-opid,args
being the secret in this example). Note that we're replacing elements of the argv[]
array of pointers, not overriding the strings pointed to by those pointers which is why our modifications don't show in the output of ps
.
With gdb
, still for non-setuid/setgid dynamically linked executables and on GNU systems:
tmp=$(mktemp) && cat << EOF > "$tmp" &&
break __libc_start_main
commands 1
set argv[1]="-opid,args"
continue
end
run
EOF
gdb -n --batch-silent --return-child-result -x "$tmp" --args ps '*****'
rm -f -- "$tmp"
Still with gdb
, a non-GNU specific approach that doesn't rely on executables being dynamically linked or have debug symbols and should work for any ELF executable on Linux at least could be:
#! /bin/sh -
# gdb+sh polyglot script to replace "*****" arguments with the content
# of the SECRET environment variable *after* execve and before calling
# the executable's main() function.
#
# Usage: SECRET=somesecret cmd --password '*****'
if ':' - ':'
then
# running in sh
# retrieve the start address for the executable
start=$(
LC_ALL=C objdump -f -- "$(command -v -- "${1?}")" |
sed -n 's/^start address //p'
)
[ -n "$start" ] || exit
# re-exec ourself with gdb.
exec gdb -n --batch-silent --return-child-result -iex "set \$start = $start" -x "$0" --args "$@"
exit 1
fi
end
# running in gdb
break *$start
commands 1
# The stack on startup contains:
# argc argv[0]... argv[argc-1] 0 envp[0] envp[1]... 0 argv[] and envp[] strings
set $argc = *((int*)$sp)
set $argv = &((char**)$sp)[1]
set $envp = &($argv[$argc+1])
set $i = 0
while $envp[$i]
# look for an envp[] string starting with "SECRET=". We can't use strcmp()
# here as there's no guarantee that the debugged executable has such
# a function
set $e = $envp[$i]
if $e[0] == 'S' && \
$e[1] == 'E' && \
$e[2] == 'C' && \
$e[3] == 'R' && \
$e[4] == 'E' && \
$e[5] == 'T' && \
$e[6] == '='
set $secret = &($e[7])
# replace SECRET=xxx<NUL> with SECRE=<NUL>
set $e[5] = '='
set $e[6] = '\0'
# not calling loop_break as that causes a SEGV with my version of gdb
end
set $i = $i + 1
end
if $secret
# now looking for argv[] strings being "*****" and replace them with
# the secret identified earlier
set $i = 0
while $i < $argc
set $a = $argv[$i]
if $a[0] == '*' && \
$a[1] == '*' && \
$a[2] == '*' && \
$a[3] == '*' && \
$a[4] == '*' && \
$a[5] == '\0'
set $argv[$i] = $secret
end
set $i = $i + 1
end
end
# using "continue" as "detach" causes a SEGV with my version of gdb.
continue
end
run
Testing with a statically linked executable:
$ SECRET=/proc/self/cmdline ./replace_secret busybox cat '*****' | tr '\0' '\n'
/bin/busybox
cat
*****
When the executable may be static, we don't have a reliable way to allocate memory to store the secret, so we have to get the secret from somewhere else that is already in the process memory. That's why the environ is the obvious choice here. We also hide that SECRET
env var to the process (by changing it to SECRE=
) to avoid it leaking if the process decides to dump its environment for some reason or execute untrusted applications.
That also works on Solaris 11 (provided gdb and GNU binutils are installed (you may have to rename objdump
to gobjdump
).
On FreeBSD (at least x86_64, I'm not sure what those first 24 bytes (which become 16 when gdb (8.0.1) is interactive suggesting there may be a bug in gdb there) on the stack are), replace the argc
and argv
definitions with:
set $argc = *((int*)($sp + 24))
set $argv = &((char**)$sp)[4]
(you may also need to install the gdb
package/port as the version that otherwise comes with the system is ancient).
ps
is not doing anything magical to "sniff out your secrets". Anyway, reasonably-written programs instead should offer a command-line option to read a secret from a specified file or from stdin instead of taking it directly as an argument. – jamesdlin Nov 11 '17 at 10:42