Start with the path, not the scary function
Finding system(), exec(), shell_exec() or a process wrapper is always interesting, but it is only the start.
When I review command-injection code I want to answer four simple questions:
- Where can I control the input? HTTP parameter, configuration value, database field, uploaded filename, API object, environment variable, and so on.
- What happens to it on the way down? Concatenation, quoting, encoding, validation, normalization or some helper function that quietly changes the value.
- Where is the command really built? I want to see both the executable and the point where a shell such as
sh -c,bash -cor backticks comes into play. - What do I need before I can reach it? Authentication, a specific role, a feature flag, a scheduled job, a restart or another trigger.
I normally write the route down like this:
input I control
↓
application parsing
↓
validation / transformation
↓
command construction
↓
shell or process API
↓
actual execution
That small exercise prevents a lot of false positives.
Check whether a shell is involved
There is an important difference between handing arguments directly to a process and building one big string that a shell will interpret.
Conceptually:
execve("program", ["program", user_input])
is very different from:
/bin/sh -c "program " + user_input
The second version gives shell metacharacters a chance to do something useful for an attacker.
So I never stop at “this code launches a process.” I check how it launches that process.
Prove it with the smallest useful test
Once the data flow looks exploitable, I start small. The goal is to prove command execution, not to win a payload beauty contest.
Useful lab checks are:
- a short delay;
- a harmless marker file;
- an outbound DNS or HTTP request to infrastructure I control;
- command output when the application already reflects it.
If id is enough to show the execution user, there is no reason to start with a complicated reverse shell.
Do not forget the trigger
Some of the nicest command injections do not execute immediately.
I have run into cases where the value is stored first and only used later by:
- a cron job;
- a backup task;
- a service reload;
- a diagnostic action;
- an export job;
- a separate administrative workflow.
That delay matters. It changes both the exploit path and the risk rating.
I normally record the preconditions explicitly:
Authentication : unauthenticated / authenticated
Required role : none / user / administrator
Trigger : immediate / restart / scheduled job / manual action
Execution user : www-data / application account / root / unknown
Exposure : local / management network / Internet reachable
A few mistakes that are easy to make
- Seeing
system()and declaring victory without tracing the source. - Missing sanitization between the input and the command.
- Assuming quotes make a shell command safe.
- Forgetting a delayed trigger.
- Proving execution but never checking the resulting OS user.
- Mixing one product-specific payload with the reusable root-cause explanation.
How I like to write the finding
A short root-cause statement should tell the reader where the value comes from, what goes wrong, where it executes and what triggers it.
For example:
User-controlled input from an administrative configuration value is concatenated into a shell command without sufficient neutralization. The command is executed later by the backup process, allowing the configured value to inject operating-system commands in the context of the backup service.
Then I put the real request, payload and product-specific details in the related Research entry. That keeps this page useful when I am looking at the next application.