Good security is no longer optional for software developers. It never really was, but today we face the explosion of supply chain attacks. These are sped along, of course, by coding agents that produce a flood of code contributions to open source projects, often too much code to reasonably review, and make it much easier for bad actors to write malicious code. So I don’t just have to keep someone from getting my system password. I have to prepare for the possibility that some malicious code will get onto my system through the back door–through one of the thousands of open source packages I use and update every day. That means I have to prepare for the possibility that some process is on my system and running as me. How in the world do I protect secrets like authentication credentials against that?
Getting my secrets somewhere safe
For my ssh and pgp keys, I opted to store them on a physical YubiKey and require my physical touch for every use of the credential. I can’t sign a git commit or connect over ssh to our servers without reaching over and touching the key each time. That means even if a malicious process gets onto my machine, running as me, it still can’t use those credentials without me seeing my YubiKey flash and touching it. That gives me a fighting chance to notice that some malicious code is trying to call out with my data, and stop the request.
But what about the tokens used by shell commands like gh or aws? I built a small utility called simplesec that stores my secrets with PGP encryption and retrieves them on demand. My PGP agent is configured to require a physical touch of my YubiKey each time a secret is retrieved. That’s a good first step, but I have to somehow feed those retrieved secrets into each gh or aws command. How?
This question sent me down a rabbit hole looking at various methods of injecting secrets and the security holes each of them leaves. In some ways it was a depressing search. It’s often impossible to get a secret value into a command without exposing it somewhere. But there are definitely better and worseoptions.
What about shell substitution?
My first naive thought was “Just use shell substitution! Easy!” So I could do something like this:
curl -H "Authorization: Bearer $(simplesec get github/token)" https://api.github.com/user
or
somecli --password "$(simplesec get db/pass)"
But I quickly realized that this still exposed the actual secret value in a lot of places.
This post is my attempt to get a full overview of how malware might view those retrieved secrets. Some of these routes expose a secret to any user on my machine (other UID), other routes expose it only to processes running under my UID (same UID), and still others can only be accessed by the root user. Some of these points of exposure can be closed or mitigated, and there are alternative ways to get secrets into commands. But to begin with I want to take shell substitution as a case study and ask, “If I were to inject secrets this way, what are all the ways a malicious process could see that sensitive value?”
NOTE: I’m focusing here on Linux and MacOS systems because…I have no idea what goes on under Windows. It’s not part of my world.
The holes
Shell history (other UID)
If a process with my UID inspects the bash history or zsh history they can see the command that retrieved the secret, not the secret itself. But this still makes it easier for malware to discover our secret store mechanism and try to exploit it.
Process argv (same UID)
If any user running under my UID runs a standard ps command, the resolved value of the substituted secret is visible in the default output as long as the command is running. On Linux this can also be done by polling /proc/<pid>/cmdline and on MacOS by using proc_pidinfo. This makes it trivially easy for malware running as me to harvest the secret. It used to be easy for other users to see the same resolved values, but on modern Linux and MacOS (properly configured) other users generally can’t.
Shell memory during expansion (same UID)
This exposure is extremely brief (often milliseconds) but the resolved secret does sit in the memory heap of the shell running the command very briefly. On Linux, process_vm_readv could watch the shell process from outside and theoretically catch the resolved secret during the brief window the shell is resolving it. This is much more difficult on MacOS, so there a bad actor would have to use software running inside the same shell process–like a compromised shell plugin or rc file (see the section on wrapped processes below).
Command process memory while running (same UID)
The more realistic scenario on Linux is for malware to inspect the running command process itself using the same process_vm_readv command and search through the heap for the resolved secret. ptrace or a debugger like gdb or lldb can also be attached to a running process and inspect its memory, but this is harder to do without detection since both require stopping the process. MacOS, on the other hand, makes this kind of inspection difficult, often impossible without triggering permission prompts.
xtrace shell output to stderr (same UID / CI )
If a command is run in a shell with debug mode (the -x flag) turned on, this prints shell xtrace output to stderr–including the resolved value of substituted secrets. That output can also be directed to a file that could be read by anyone with access. But it requires that the command be run in such a shell. For malware to turn this mode on, it would have to be already running inside the shell process–again likely via a compromised shell plugin or rc file.
The more common vector for this vulnerability is CI jobs. It’s not uncommon for a CI task to run a shell command with -x to facilitate seeing exactly what is being run. A compromised CI job (like a Github action) might turn this on deliberately. In that case the resolved secret value would often be included in the CI task logs, and so be exposed in plain text to anyone with permission to read them. This can be prevented on Github by careful masking, as we’ll see below.
Crash reports/core dumps (root / same UID)
Resolved substitution values can also appear in files written after an application crashes abnormally (e.g., due to a segfault) or in some cases when it is killed. On MacOS, Crash Reporter writes a .ips diagnostic report under something like ~/Library/Logs/DiagnosticReports/, readable by any same-UID process. On modern Linux a full core dump is often written to a file owned by root somewhere like /var/lib/systemd/coredump/, readable via coredumpctl or journalctl. Generally the full core dump is only accessible to the root user and requires sudo, although it is possible for sections of the dumped memory to appear in journal output readable by same-UID processes. Still, at this point modern Linux (using systemd-coredump) is generally more secure.
Audit/EDR logs (root)
On systems with Audit or EDR logging configured, this can also contain the resolved value of substituted secrets. Audit logs can record every process that runs on a system, including the arguments passed to them (if they log execve). This is done by auditd or kernel hooks on Linux, MacOS uses OpenBSM. EDR security software like CrowdStrike, SentinelOne, etc. records the same process information and usually sends it to a central server. But in both cases, these logs are usually only viewable by someone with root permission, audit group members (on Linux), and any EDR/SIEM agent process along with the people withaccess to its dashboard. A process that simply runs with my UID can’t view them.
CI logs (anyone)
The picture is somewhat different for CI logging. We already looked at the risk of catching secrets when processes are run with -x and logged. Those are recorded because there’s a log kept of stdout and stderr for each step in, e.g., a Github workflow. For public Github repositories (like our Knowledge Commons ones), these logs are readable by anyone on the internet. This is a major source of confidential data leaks in general.
One solution is just to ensure that your workflows don’t run commands with the-x flag. Another solution is to avoid command substitution for Github workflows and instead use Github’s own secrets management (secrets.*), which automatically masks secret values in the step logs. (This is actually the preferred way to do it.) If it is necessary to use some kind of command substitution (e.g., to fetch a secret at runtime from an external source) these secrets can also be masked using ::add-mask:: in the workflow as long as that mask is invoked before the secret appears in the log output.
This is distinct from Github’s org/enterprise audit log. That keeps a record of who runs what, but it does not record any execution arguments (argv) or command results (stderr, stdout). If a workflow runs on a self-hosted Github runner, there’s the possibility that the runner host is also doing audit/EDR logging. But this is not an issue on Github-hosted runners.
Of course, if some malware is running in your running CI process (e.g., it was pulled in as part of a dependency package) then all the same means of access are open to it that exist on a local system or server.
Wrapped executables on PATH
Maybe the most insidious kind of infiltration will wrap an executable app with its own runner code. When I think I’m calling the gh CLI tool, I’m actually calling a malware application that has hijacked its name. That fake gh application then runs the real gh for me and I don’t even realize it’s there. This is done by modifying the system’s PATH, the list of folders the operating system checks to find executable app files. When I call gh, the shell reads PATH from left to right and checks each folder for a file with the name gh. The first matching file it finds is the one that the shell executes. So if a bad actor modifies PATH to add a folder at the beginning, it can place its own files in that folder and give them the names of whatever apps they want to wrap.
There are three kinds of targets here:
(a) A secrets management tool like simplesec can be wrapped, in which case the runner code can intercept the tool’s output like this:
exec /real/simplesec "$@" | tee /tmp/out
It will capture every secret the tool retrieves when the substitution calls it.
(b) The app that’s receiving the secret can be wrapped. In this case when we resolve the secret as an argument, we hand it directly to the malicious wrapper code before it ever reaches the actual command we’re trying to run. The weakness of this approach is that an attacker has to decide which processes to wrap with a runner. So it either has to know its target ahead of time (maybe targeting common commands like curl, gh, aws, etc.) or it needs to somehow discover likely targets on my machine.
(c) The shell application itself can be wrapped (bash, zsh, etc.), in which case the runner can attach listeners like debuggers, log the xtrace output, etc.
The PATH is most often modified by rewriting shell startup files like .bashrc or .zshrc. This can also be done by a malicious shell plugin installed by me. Since PATH is just an environment variable, malware running under my UID can also simply inject their own env variable when it executes a command:
PATH="/tmp/evil:$PATH" ./my-build-script.sh
In fact, this technique can be used when the malware modifies any process that can accept an injected env variable: git hooks, npm postinstall scripts, CI steps, dockerfiles, etc. If malware under my UID can modify the execution of any process to inject an env variable, it can wrap that executable. All of this requires, again, that the malware is targeting a specific application to wrap, and that it’s able to write its own malicious file in a new folder.
The sobering reality of command substitution
The hard truth is that there are a lot of ways secrets can be observed if I inject them using shell substitution. Now, there are some ways to mitigate these risks. And most of them are only available to malware running under my UID. The problem is, of course, that if I bring in a compromised package in my supply chain, it will likely be executed as me. Still, under regular OS configuration, any user on my system can read resolved substitute values in argv by running ps and observing the process list. Or by reading process files in /proc/<pid>/cmdline on Linux.
I should emphasize this does not make secrets injection useless!! It is still meaningfully harder to discover the resolved values than it is to read them from plain text sitting around in an .env file. And in the next post in this series I’ll collect some mitigation strategies. I’ll also cover some alternative methods of injecting secrets that give us a bit more protection. The quest for system security continues!








