CoursesAdvanced scripting for DevSecOpsBash internals: subshells, FDs & process substitution

Bash internals: subshells, FDs & process substitution

Subshells vs the current shell, file descriptors, process substitution and coprocesses.

Advanced35 min · lesson 1 of 15

Most advanced Bash bugs trace back to one question you never thought to ask: did the shell start a brand-new process to run this, or did it run it right here in the shell you are sitting in? Get that wrong and your script quietly loses track of things. A counter that should read 47 reads 0. A secret you fetched comes back empty. A security gate that should fail passes clean.

Here is the mental model. When Bash runs part of your script in a subshell, it is like handing a photocopy of your notebook to an assistant, sending them into a side room, and locking the door. They can scribble all over that copy. When they walk back out, they hand you nothing but a thumbs-up or thumbs-down (the exit status) and whatever they read aloud on the way (their output). Your original notebook is untouched. A subshell (a child process, meaning a separate running copy of the shell) gets its own copy of every variable, its own current directory, its own everything. When it ends, its changes evaporate.

Where Bash Quietly Forks

So the real skill is spotting where subshells appear, because they are not always obvious. Parentheses ( ... ) run their contents in a subshell. A brace group { ...; } runs in the current shell, same process, so its changes stick. Command substitution $(...) runs in a subshell. Backgrounding a job with & runs it in a subshell. And the one that catches everybody: every stage of a pipeline a | b | c runs in its own subshell.

~/secopslog — bash
$ x=1; { x=2; }; echo "brace:$x" x=1; ( x=2; ); echo "parens:$x"
brace:2 parens:1

The brace group changed x for real. The parentheses changed a copy that no longer exists. Same-looking syntax, opposite outcome.

Current shell, or a subshell?
You run a piece of code. Where does its state live?
{ ...; }
Current shell
Same process; variable and cd changes stick around.
( ... ) or $(...)
Subshell
Gets a copy of everything; changes vanish when it exits.
a | b | c
Each stage is a subshell
Unless shopt -s lastpipe, and only for the final stage.
cmd &
Backgrounded subshell
Runs off to the side; its state never comes back to you.

The Pipeline That Eats Your Counter

This is where the security payoff starts. Say you are counting something that matters: failed SSH (Secure Shell, the encrypted remote-login protocol) logins, packages with known vulnerabilities, files an attacker touched, and the count decides whether a check passes. You pipe a stream into a while loop. The loop runs in a subshell. It counts perfectly. Then it dies, and takes your count with it.

~/secopslog — bash
$ count=0 printf 'a\nb\nc\n' | while read -r line; do count=$((count+1)); done echo "$count"
0

Three lines went by. The count is 0. The loop really did run and really did reach 3, but that 3 lived in the subshell on the right side of the pipe and died with it. If this loop were counting critical findings to gate a deploy, you would ship with the gate wide open and never see it.

Two clean fixes. The first keeps the loop in your current shell by feeding it through a redirection instead of a pipe, using process substitution (which the next sections explain in full):

~/secopslog — bash
$ count=0 while read -r line; do count=$((count+1)); done < <(printf 'a\nb\nc\n') echo "$count"
3

The second fix tells Bash to run the last stage of a pipeline in the current shell. That is the lastpipe option, added in Bash 4.2. It only takes effect when job control is off, which is already the default inside scripts; at an interactive prompt you turn job control off with set +m first:

~/secopslog — bash
$ shopt -s lastpipe set +m count=0 printf 'a\nb\nc\n' | while read -r line; do count=$((count+1)); done echo "$count"
3

Now the count survives. Pick whichever reads clearer. The redirection form works even on Bash older than 4.2, so it is the portable choice; lastpipe is tidier when you already have a natural pipeline you do not want to rewrite.

File Descriptors: The Wiring Behind Redirection

Redirection looks like special syntax. It is really you rewiring numbered slots. Every process is born with three numbered mail slots, and the kernel (the core of the operating system that talks to the hardware) uses those numbers to know where reading and writing go. These slots are file descriptors (small integers that stand for an open file, pipe, socket, or terminal). Slot 0 is stdin (standard input, where input arrives). Slot 1 is stdout (standard output, where normal results go). Slot 2 is stderr (standard error, where error messages go). You can open more slots yourself: 3, 4, and up.

The notation 2>&1 means point slot 2 at wherever slot 1 currently points. Read that slowly. It copies the destination that slot 1 has at that exact instant. It does not tie the two slots together for the future. That is why the order of your redirections changes the result.

~/secopslog — bash
$ # stdout to the file, then stderr copies stdout's target (the file too) ls /nope /etc/hostname >out.txt 2>&1 echo '--- nothing printed above; out.txt holds: ---' cat out.txt
--- nothing printed above; out.txt holds: --- ls: cannot access '/nope': No such file or directory /etc/hostname
$ # stderr copies stdout's CURRENT target (the terminal), THEN stdout moves to the file ls /nope /etc/hostname 2>&1 >out.txt echo '--- the error above hit the terminal; out.txt holds: ---' cat out.txt
ls: cannot access '/nope': No such file or directory --- the error above hit the terminal; out.txt holds: --- /etc/hostname

Same two words, swapped order, and the error lands in a different place. This bites in logging. A monitoring agent that ingests your script's stdout expects clean data; the errors belong on stderr where a human or an alert pipeline reads them. Write cmd 2>&1 >log when you meant cmd >log 2>&1 and your errors leak into the data stream or drop on the floor, and your alerting goes silent at the exact moment something is breaking.

Open Your Own Descriptors

You are not stuck with the three slots you were born with. You can open your own, park the original stdout safely to one side, redirect for a while, then put it back exactly as it was. Think of it as propping a specific door open with a numbered wedge so you can find that same door again later.

exec with no command after it changes the shell's own descriptors and keeps them changed. Here is an append-only log sink on descriptor 3, the real stdout stashed in descriptor 4, and a clean restore afterward:

~/secopslog — bash
$ exec 3>>/var/log/tool.log # FD 3: append to the log, never truncate it exec 4>&1 # FD 4: stash the real stdout for later exec 1>&3 # send stdout into the log echo "audit: scan started at $(date -Is)" exec 1>&4 4>&- # restore stdout from the stash, then close the stash echo "back on the terminal"
back on the terminal
/var/log/tool.log
audit: scan started at 2026-07-17T10:22:14+00:00

Only the last line reached your terminal. The audit line went into the log through descriptor 3. Two details carry weight for security work. Using >> (append) instead of > (truncate) means reopening the log never wipes what is already there, which is exactly what you want from an audit trail. And stashing the real stdout in descriptor 4 before you redirect gives you a guaranteed way back; you are not guessing where stdout used to point, you saved it.

Process Substitution: A Command Dressed As A File

Plenty of tools refuse to read from a pipe. They demand a filename you can point at. Process substitution is Bash handing such a tool a fake filename that is secretly a live command. It is like giving someone a P.O. box number instead of a house: mail addressed to the box still reaches you, but there is no house on any street.

Write <(cmd) and Bash runs cmd, wires its output to a pipe, and hands you a path to that pipe, usually /dev/fd/63. The tool opens that path like a file and reads the command's output. Write >(cmd) for the reverse: a path you write into, which feeds the command's input.

~/secopslog — bash
$ echo <(true) ls -l <(echo hi)
/dev/fd/63 lr-x------ 1 you you 64 Jul 17 10:22 /dev/fd/63 -> pipe:[723418]

That /dev/fd/63 is not a file on disk. The arrow gives away what it really is: a pipe. Nothing touched the filesystem, and that is the whole point for security work.

Temporary files are a classic weak spot. You write sensitive output to /tmp/scan.json, a tool reads it a moment later, and in that gap an attacker on the same box can swap the file for a symlink or read your secrets straight off a world-readable path. That gap has a name: a TOCTOU bug (time-of-check to time-of-use, a race where the file you checked or created is not the file you actually end up using). Process substitution shuts the gap by never creating a file at all. Three patterns come up constantly (kubectl is the Kubernetes command-line tool):

~/secopslog — bash
$ # spot config drift between two live namespaces, nothing hits disk diff <(kubectl get cm app -n prod -o jsonpath='{.data}') \ <(kubectl get cm app -n staging -o jsonpath='{.data}') # fan one stream out to several consumers at once generate_report | tee >(gzip >report.gz) >(sha256sum >report.sha) >/dev/null # hash a download stream without saving the file first openssl dgst -sha256 <(curl -fsSL https://example.com/artifact)
1c1 < map[LOG_LEVEL:debug TIMEOUT:30] --- > map[LOG_LEVEL:info TIMEOUT:30] SHA2-256(/dev/fd/63)= 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

Coprocesses: A Helper You Keep On The Line

Sometimes you do not want a fresh process for every question. You want one helper running and you want to keep talking to it: ask, hear back, ask again, without hanging up and redialing each time. That is a coprocess. Bash starts a background command and wires two pipes to it, one you write into (its input) and one you read from (its output). It even remembers its process id for you.

coproc NAME { command; } launches command in the background. Bash fills an array: ${NAME[1]} is the descriptor you write to, ${NAME[0]} is the descriptor you read from, and NAME_PID holds its process id. Here is a coprocess that upper-cases whatever line you send it:

~/secopslog — bash
$ coproc UP { awk '{ print toupper($0); fflush() }'; } printf 'deploy\n' >&"${UP[1]}" read -r out <&"${UP[0]}" echo "$out" exec {UP[1]}>&- # close its input so the helper can finish wait "$UP_PID"
DEPLOY

Notice the fflush() in that awk. It is not decoration. Many programs, when they detect their output is a pipe rather than a terminal, hold output in a buffer instead of sending it line by line. Your read then waits forever for a line that is stuck in the buffer. That is the number one reason coprocesses hang. The fix is to make the helper flush after each line (fflush() in awk, or stdbuf -oL in front of other tools, which forces line buffering). Coprocesses earn their keep when the helper is expensive to start, a policy engine you query per resource, a signing or decryption agent you keep warm, so you pay the startup cost once instead of thousands of times.

set -e is not the safety net you think
errexit (set -e, which tells Bash to stop on the first failing command) looks straight past any failure that gets used. The left side of && or ||, a command inside an if or while test, a command after !, none of those abort the script. The sharpest edge is assignment. A bare x=$(false) does trip set -e in modern Bash, but the moment you prefix it with local, declare, export, or readonly, the exit status becomes that of local (which succeeds), so the failure disappears. That means local token=$(get_secret) sails on with an empty token even when get_secret failed. Split the declaration from the call, or check the status yourself. For pipelines, read ${PIPESTATUS[@]}, which holds every stage's exit code, not only the last.
~/secopslog — bash
$ set -e get_secret() { return 1; } # pretend the fetch failed load() { local token=$(get_secret); echo "kept going with token=[$token]"; } load echo "and the script marches on"
kept going with token=[] and the script marches on

The failed fetch left token empty, local swallowed the non-zero status, and set -e never fired. A script written this way will happily authenticate with nothing and keep running. Before you trust any script that gates a deploy or counts security findings, run it once under set -x (which prints each command as Bash executes it) and watch for the parentheses, pipes, and $(...) where your state forks off and dies. That one habit catches most of these bugs before they catch you.

Quick check
01A script runs myscan 2>&1 >scan.log. Where do the error messages (stderr) end up?
Incorrect — That is what >scan.log 2>&1 does; here the two redirections are in the reverse order, so it does not happen.
Correct — redirections apply left to right, and 2>&1 duplicates wherever stdout points at that instant, which is still the terminal.
Incorrect — They do not conflict. Both take effect, one after the other, in order.
Incorrect — Whether the file exists has nothing to do with which stream lands where.
02In the lesson, diff <(kubectl get cm app -n prod -o jsonpath='{.data}') <(kubectl get cm app -n staging -o jsonpath='{.data}') compares two live configs. What does each <(...) actually hand to diff, and why does that matter for security?
Incorrect — the whole point of process substitution is that no file is created, so there is no /tmp file to plant a symlink against or leave behind.
Incorrect — diff is handed a path it opens like a file, not the literal bytes of the output on the command line.
Correct — Bash wires the command's output to a pipe and gives diff a /dev/fd path, closing the temp-file (TOCTOU, time-of-check to time-of-use) gap by never touching the filesystem.
Incorrect — it is an anonymous /dev/fd pipe managed by Bash, with nothing left in the directory for you to clean up.
03A deploy gate runs: bad=0; grep Failed auth.log | while read -r l; do bad=$((bad+1)); done; echo "$bad". It prints 0 even though grep matches thousands of lines, so the gate passes wide open. What is the cause and which fix keeps the count?
Incorrect — grep never touches the bad variable; the loss comes from where the loop runs, not from grep.
Correct — every pipeline stage is a subshell, so the incremented bad evaporates when the subshell exits; process substitution keeps the loop in the current shell so the count survives.
Incorrect — -r only stops backslashes in the data from being treated as escapes and has nothing to do with the counter.
Incorrect — $((...)) is arithmetic evaluation and increments correctly; the value is lost to the subshell, not miscomputed.

Try this

Work through “Coprocesses: A Helper You Keep On The Line” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: set -e is not the safety net you think. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related