My Hash Check Passed on Two Empty Strings

A download check printed sha256 OK when both hashes were empty. How a shell setting caused it, a reproduction, and checks that fail loudly instead.

On this page
  1. A green light with nothing behind it
  2. How both sides came back empty
  3. Reproducing it in five lines
  4. Would set -e have caught it? Only sometimes
  5. Two checks that fail loudly instead
  6. The rule I added to my scripts

A download-verify step in one of my scripts printed sha256 OK while comparing two empty strings. Nothing had been verified at all. The fix is simple and worth adding to every script you have: before comparing two hashes, prove each one is non-empty and the right shape (64 hex characters for SHA-256), or let sha256sum -c do the comparing for you.

This is another entry in my field notes, the running log of things that went sideways in my lab. Like my backups that silently failed for a week, the scary part isn’t that something broke. It’s that the check designed to catch it said everything was fine.


A green light with nothing behind it

It happened on a busy day of deploys, the same day I set up append-only laptop backups. One step downloaded a compressed release file and checked its SHA-256 hash against the published one before using it. A boring, sensible step. My script fetched both, compared them, and printed sha256 OK.

Then the next step, decompressing the file with bunzip2, failed loudly. That failure is the only reason I noticed anything was wrong.

When I went back and looked, the check hadn’t compared two hashes. It had compared nothing with nothing. The expected hash was an empty string, the actual hash was an empty string, and as far as [ "$expected" = "$actual" ] is concerned, those are equal.


How both sides came back empty

The script ran with set -u, a bash option that makes using an unset variable an error instead of quietly treating it as empty. That’s usually a good habit. But the shell my scripts were running in loaded a saved environment file first, and that file referred to $ZSH_VERSION, a variable that only exists in zsh. In bash, under set -u, that’s an “unbound variable” error.

The error happened inside each $(...) command substitution. The substitution’s little subshell died before printing anything, so each one handed back an empty string, and the comparison carried on with what it got. In the reproduction below you can see the error lines appear, but they sit right above a confident sha256 OK, which is exactly the line you want to read.


Reproducing it in five lines

I wanted to be sure I understood it, so I rebuilt the failure in a scratch directory. SHELL_FLAVOR stands in for the variable my environment file referenced; it’s unset, just like ZSH_VERSION was under bash.

verify-naive.sh: the shape of my original check

#!/usr/bin/env bash
set -u
expected=$(echo "$SHELL_FLAVOR" >/dev/null; awk '{print $1}' SHA256SUMS)
actual=$(echo "$SHELL_FLAVOR" >/dev/null; sha256sum release.bin | awk '{print $1}')
if [ "$expected" = "$actual" ]; then echo "sha256 OK"; else echo "sha256 MISMATCH"; fi
Running it (GNU bash 5.3)

$ bash verify-naive.sh; echo "exit=$?"
verify-naive.sh: line 3: SHELL_FLAVOR: unbound variable
verify-naive.sh: line 4: SHELL_FLAVOR: unbound variable
sha256 OK
exit=0

Two error lines, then a confident sha256 OK, then exit code 0. If this ran inside a larger automation, nothing downstream would know anything had gone wrong.

Empty equals emptyexpected=$(…)should be 64 hex chars""actual=$(…)should be 64 hex chars""[ "" = "" ]truesha256 OKexit 0, nothing checkedThe fix: check that each side is 64 hex characters before comparing, or let sha256sum -c compare.peira.dev

Would set -e have caught it? Only sometimes

My script only had set -u. The obvious question is whether adding set -e, “exit on error”, would have caught this. It depends on exactly how the script is written, which is a good reason not to lean on it.

When the substitution is a plain assignment like expected=$(...), the bash manual says the assignment’s exit status is the exit status of the last command substitution. So a failed substitution makes the assignment fail, and set -e stops the script. In my test, changing the first line to set -eu made the script exit with code 1 on line 3, before it ever compared anything.

But move the substitutions into the test itself, which is a perfectly normal way to write it, and the result flips:

Same check, substitutions inline, with set -eu

$ cat verify-e-inline.sh
#!/usr/bin/env bash
set -eu
if [ "$(echo "$SHELL_FLAVOR" >/dev/null; awk '{print $1}' SHA256SUMS)" = "$(echo "$SHELL_FLAVOR" >/dev/null; sha256sum release.bin | awk '{print $1}')" ]; then echo "sha256 OK"; else echo "MISMATCH"; fi

$ bash verify-e-inline.sh; echo "exit=$?"
verify-e-inline.sh: line 3: SHELL_FLAVOR: unbound variable
verify-e-inline.sh: line 3: SHELL_FLAVOR: unbound variable
sha256 OK
exit=0

Same set -eu, same failure, and it still prints sha256 OK with exit 0. The failure has nowhere to stop the script: the test just receives two empty strings and compares them. A safety net that works or doesn’t depending on where you put the parentheses isn’t one I want to rely on for a security check.


Two checks that fail loudly instead

Option one: prove the shape before you compare. A SHA-256 hash written in hex is always exactly 64 characters from 0-9a-f. Check that for both values first, and an empty string can’t sneak through. This is the exact script I tested, so it keeps the same two SHELL_FLAVOR lines that simulate the broken environment:

# verify-strict.sh: assert both hashes are real before comparing
#!/usr/bin/env bash
set -u
expected=$(echo "$SHELL_FLAVOR" >/dev/null; awk '{print $1}' SHA256SUMS)
actual=$(echo "$SHELL_FLAVOR" >/dev/null; sha256sum release.bin | awk '{print $1}')
for name in expected actual; do
  value=${!name}
  if ! [[ $value =~ ^[0-9a-f]{64}$ ]]; then
    echo "FAIL: $name hash is not 64 hex characters (got ${#value})" >&2
    exit 1
  fi
done
if [ "$expected" = "$actual" ]; then echo "sha256 OK"; else echo "sha256 MISMATCH" >&2; exit 1; fi

I ran this version three ways. With SHELL_FLAVOR unset (the broken environment), it stopped with FAIL: expected hash is not 64 hex characters (got 0) and exit code 1. With SHELL_FLAVOR set (a working environment), it printed sha256 OK. And with SHELL_FLAVOR set but the file’s contents changed, it printed sha256 MISMATCH and exited 1. That’s the behaviour you want from a check: it can only say OK when it actually compared two real hashes.

Option two: don’t compare strings yourself at all. GNU sha256sum -c reads a checksum file, hashes each file it lists, and does the comparison itself. It exits non-zero on a mismatch (release.bin: FAILED when I tampered with the file), and it also refuses to treat an empty checksum file as a pass:

sha256sum -c on a good file, then on an empty checksum file

$ sha256sum -c SHA256SUMS; echo "exit=$?"
release.bin: OK
exit=0

$ sha256sum -c empty.sums; echo "exit=$?"
sha256sum: empty.sums: no properly formatted checksum lines found
exit=1

That’s from GNU coreutils 9.12. When a project publishes a checksum file, this is the option I reach for now. My Ventoy guide already works this way, checking the download against the release’s own checksum list.

Keep one loud step downstream

What actually saved me wasn’t the check. It was the next step, bunzip2, failing loudly on a file it couldn’t use. Even with a good check in place, it’s worth having something after it that will crash straight into reality if a bad input gets through.


The rule I added to my scripts

An equality check is only a verification if both sides are proven to be real values first. Empty equals empty is a true statement, and a script will happily report it as success. So every hash comparison in my scripts now either asserts length and format before comparing, or hands the job to sha256sum -c.

The same trap shows up anywhere a check compares two things that might both be missing: two empty file lists that “match”, a count of zero errors from a log that was never read. If a check can pass without looking at anything, sooner or later it will.


Related posts:

Comments

Comments are powered by GitHub Discussions — sign in with a GitHub account to join the conversation.