Hacktakes · Edition 11
Hacktakes · Edition 11 · July 23, 2026

who actually parses `--`? (it's not the shell!)

Because the shell passes arguments untouched, applications parse the double-dash in user space where custom rules lead to injection vulnerabilities.

By Poppy Lin

Sparked by git's –end-of-options Flag · discussion

I don't intercept the hostility, Madam Ambassador, I merely pass it untouched.
I don't intercept the hostility, Madam Ambassador, I merely pass it untouched.

For the longest time I assumed I grasped how command line arguments worked, but I TOTALLY DIDN'T. I always believed that if a user typed something like rm -- -file.txt, the shell somehow magically intercepted that double-dash flag, stripped it out of the command, and whispered to the rm executable that the next string was a file name instead of a flag. But yesterday I was reading a thread about argument injection bugs, and I realized I had absolutely no idea what the shell actually handles versus what the application parses.

So, instead of guessing or trying to read giant man pages, we can look at the raw data. Imagine a scenario where someone writes a bare-minimum, five-line C program called print_args that contains a simple loop to print every single string inside the argv array to the terminal. If that program is executed with a test file, the terminal outputs this exact block of raw text:

$ ./print_args -- -file.txt
argv[0] = ./print_args
argv[1] = --
argv[2] = -file.txt

Okay, the shell did not intercept a single thing! The trace clearly shows argv[1] is literally just the raw string --, and argv[2] is -file.txt. Bash just took the dashes and passed them straight through to the execve system call untouched, leaving the program to deal with the messy strings.

And this reveals a major fundamental shift in my mental model, because argument parsing turns out to be entirely devoid of OS-level magic. The operating system just hands the program a dumb array of text, meaning parsing functions as a regular old user-space while loop running inside the application itself.

Usually, developers do not write this loop from scratch, relying instead on a standard library like POSIX getopt to read the array. Here is how that parsing convention physically operates when reading flags:

  • It assumes dashes are flags: The library loops through the argv array sequentially, treating anything that starts with a - as a command option.
  • It hits a hardcoded stop: When the loop encounters the exact string --, the POSIX standard specifically dictates that the library must stop treating subsequent strings as options.
  • It treats the remainder as positional: Everything after that double-dash is handed back to the application as a raw file path or a standard positional argument, completely neutralizing any malicious strings that look like flags.

But because this is just a made-up user-space convention, any program is completely free to ignore getopt and handle the array however it wants.

This brings us directly to Git, which famously decided to overload the meaning of -- to mean something completely different: separating branch revisions from local file paths. (You can see a massive amount of historical debate about this confusing UX choice over in this Hacker News thread where developers discuss the tool's steep learning curve).

Because Git overloaded the double-dash to mean that file paths follow the flag, the tool could no longer use -- for its standard POSIX purpose of neutralizing option parsing. If a user needed to pass a revision name that happened to start with a dash, Git would get confused and attempt to parse it as a flag. This exact ambiguity leads to terrifying argument injection vulnerabilities, which you can read about in this deep dive on how Git handles end-of-options.

To fix these security gaps, Git had to invent a completely separate flag to do the job that -- was originally supposed to do. If we look at the raw text of the Git v2.24.0 Release Notes, the maintainers explicitly noted they added the new flag because the standard double-dash was "already pressed into service" for separating revisions from paths. So Git introduced --end-of-options to manually bypass the confusion.

Instead of the neat flow diagram I used to picture in my head—where Bash intercepts the double-dash to protect the program—the reality is significantly messier. Bash passes --, the OS passes -- straight into the array, and a vulnerable user-space loop has to figure out what to do with the leftover bytes.

Anyway, there is honestly still a lot I don't fully understand about getopt edge cases, like how the GNU version of the library somehow reorders arguments completely differently than the POSIX version does :) If you know more about why Git originally made that specific parsing design choice in the early days, I would love to learn more about the history!

← Back to Edition 11