$RodHat_
Console Tips

find | xargs is a filename injection bug you've been shipping for years

Published by

find | xargs is a filename injection bug you've been shipping for years
Photo: AI-generated — no human photographer / RodHat AI Cover

This line is in every ops repo on earth:

find /var/log -name '*.gz' -mtime +30 | xargs rm

It works right up until a filename has a space in it, at which point xargs splits it into two arguments and rm deletes two files that don’t exist and skips the one that does. Add a newline in a filename — which is legal on every Unix filesystem, because the only bytes forbidden in a filename are / and NUL — and it will happily delete something you never named.

That’s not a hypothetical. Anything that ingests user-supplied names — an upload directory, a mail spool, a media library, a checkout of a repo somebody else wrote — is generating those filenames right now.

The fix, and why it works

find /var/log -name '*.gz' -mtime +30 -print0 | xargs -0 rm

-print0 separates records with a NUL byte instead of a newline. -0 tells xargs to expect that. Since NUL is the one byte that can’t appear inside a filename, the separator is now unambiguous — which is the entire point, and the reason every other quoting scheme is a losing game.

While you’re there, drop the other two footguns in that pipeline:

  • xargs runs the command even with no input. xargs rm with an empty stream is a bare rm, which errors harmlessly, but xargs tar -czf backup.tar.gz with an empty stream builds you an empty archive and overwrites yesterday’s. -r (--no-run-if-empty) suppresses that. GNU only; BSD xargs already behaves this way.
  • Argument list limits. xargs splits into multiple invocations when the list exceeds ARG_MAX. Fine for rm, quietly wrong for anything that produces one output per invocation.

The better version

Most of the time you don’t need xargs at all:

find /var/log -name '*.gz' -mtime +30 -exec rm {} +

The + matters. -exec cmd {} \; runs the command once per file — a fork and exec for every single match, which on 50,000 files is a coffee break. -exec cmd {} + batches them exactly like xargs does, handles ARG_MAX itself, and never involves a shell — so there is no quoting, no splitting, and no NUL bookkeeping to get wrong. No pipe, no second process, no -print0/-0 pair to forget on one of them.

Use \; only when you genuinely need one invocation per file, which is usually when the command takes exactly one argument or you need the exit status per file.

When you still need xargs

Two real cases.

Parallelism. find -exec is serial. xargs -P isn’t:

find . -name '*.log' -print0 | xargs -0 -P 8 -n 100 gzip

Eight concurrent gzip processes, 100 files each. On a box with cores sitting idle this is the difference between minutes and hours. Watch out for output interleaving — -P with commands that print to stdout gives you shredded lines. Redirect per-command or use --line-buffered where the tool supports it.

The input isn’t from find. A file list, a database query, another program:

tr '\n' '\0' < filelist.txt | xargs -0 -n1 process

…and note that tr line only works if you’re sure there are no newlines in the names, which is the assumption that started this whole article. If the list came from something that can emit real filenames, get it emitted NUL-separated at the source. git ls-files -z, grep -lZ, sort -z, find -print0 — the tools all have the flag. Use it.

The one that gets people

xargs without -0 also treats quotes and backslashes specially, not just whitespace. A file called it's a trap.log doesn’t merely split — xargs sees an unmatched single quote and errors out or swallows everything to the next quote. Ninety percent of the “unmatched single quote” errors people hit in cron jobs are this, and the fix is never more quoting. It’s -0.

The habit

If you type find, type -exec ... {} + unless you need parallelism. If you type xargs, type -0 in the same keystroke and make sure whatever’s upstream is emitting NULs. Both are muscle memory you can build in an afternoon, and both remove an entire category of bug that only ever shows up on the one day the filename has a space in it — which, historically, is the day of the incident.