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

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:
xargsruns the command even with no input.xargs rmwith an empty stream is a barerm, which errors harmlessly, butxargs tar -czf backup.tar.gzwith an empty stream builds you an empty archive and overwrites yesterday’s.-r(--no-run-if-empty) suppresses that. GNU only; BSDxargsalready behaves this way.- Argument list limits.
xargssplits into multiple invocations when the list exceedsARG_MAX. Fine forrm, 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.