Bitware Labs Est. 2022

Bitware Labs/Notebook

Sending mail from PHP-FPM when AppArmor says no


The contact form at the bottom of this site does not call mail(). It opens a socket to 127.0.0.1:25 and speaks SMTP by hand — EHLO, STARTTLS, MAIL FROM, RCPT TO, DATA, the lot. That started as a workaround for something I could not change. It ended as the design I would now pick first.

The symptom

mail() returned false. Not an exception, not a PHP warning worth reading, not a line in the Postfix log — because Postfix was never reached. Nothing in the mail queue, nothing in /var/log/mail.log, no bounce. The message simply did not exist.

PHP's mail() does not know how to send mail. It shells out to sendmail_path, which on a Debian box with Postfix is /usr/sbin/sendmail -t -i. So the question is never "why did PHP fail to send mail" but "why did PHP fail to execute a binary", and the answer was in /var/log/syslog:

apparmor="DENIED" operation="exec"
  profile="php-fpm" name="/usr/sbin/postdrop"
  comm="sendmail" requested_mask="x" denied_mask="x"

The php-fpm AppArmor profile does not permit exec of the sendmail chain. That is not a bug. A PHP-FPM pool that can execute arbitrary setgid binaries is a considerably more interesting target than one that cannot, and postdrop is setgid postdrop by design.

The three options

Widen the AppArmor profile. Two lines, works immediately, and hands the web application the ability to exec a setgid binary — permanently, for every script in the pool, to solve one form on one page. The cost is not paid today; it is paid the day something else in that pool is compromised.

Add a mail library. PHPMailer or Symfony Mailer will do this correctly and are perfectly good software. But this is a static site with one form. Pulling in Composer, a vendor tree and a dependency to update forever, in order to write six protocol verbs to a socket, is a poor trade — and every one of those dependencies is code that runs with the same privileges as the form.

Speak SMTP directly. Postfix is already listening on loopback. It is a mail server; talking to it in its own protocol is not a hack, it is the interface. About sixty lines, no dependencies, no privilege escalation, and the failure modes are visible.

What the third option actually looks like

The shape of it, minus the error handling:

$sock = stream_socket_client('tcp://127.0.0.1:25', $errno, $errstr, 5);
stream_set_timeout($sock, 5);

expect('220');
send('EHLO bitwarelabs.com');   expect('250');
send('STARTTLS');               expect('220');
stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
send('EHLO bitwarelabs.com');   expect('250');   // re-issue after TLS
send("MAIL FROM:<$from>");     expect('250');
send("RCPT TO:<$to>");         expect('250');
send('DATA');                   expect('354');
send($headers . "\r\n\r\n" . $body . "\r\n.");
                                expect('250');
send('QUIT');

Four details in there are not optional, and each cost me something to learn.

Re-issue EHLO after STARTTLS

Everything the server told you before the TLS handshake is discarded. RFC 3207 is explicit about this: the client must issue a fresh EHLO, and the server's capability list may legitimately differ. Skip it and you will get a 503 5.5.1 Error: send HELO/EHLO first that looks like the session reset for no reason.

Multiline replies are the normal case, not the edge case

An SMTP reply is multiline when the fourth character is a hyphen — 250-PIPELINING then 250-SIZE then finally 250 DSN. A naive fgets() reads the first line, sees 250, declares success, and leaves the rest of the capability list sitting in the buffer. It will then be read as the response to your next command, and every reply from then on is off by one. This desynchronisation is the single most common way a hand-rolled SMTP client fails, and it fails intermittently, depending on which capabilities the server advertises.

do {
    $line = fgets($sock, 512);
    if ($line === false) return false;
} while (isset($line[3]) && $line[3] === '-');   // drain continuation lines
return strncmp($line, $code, 3) === 0;

Dot-stuffing

A line consisting of a single . terminates DATA. If a user's message contains such a line — and eventually one will, because people paste things — the remainder of their message gets interpreted as SMTP commands. Every line beginning with . must be sent with the dot doubled:

$data = preg_replace('/^\./m', '..', $body);

This is the injection vector in the design, and it is one line to close. Alongside it: reject \r and \n in anything that reaches a header. The name field goes into Subject:; a newline there lets a sender add arbitrary headers, including Bcc:.

Peer verification on loopback

The certificate is issued for the mail hostname, not for 127.0.0.1, so verification fails on name mismatch. Disabling it here is defensible because the traffic never leaves the machine — but "we turned off certificate verification" deserves a comment in the source explaining exactly why, or the next person to read it (which is usually you) has to re-derive whether it was reasoned or lazy.

Why I would now choose this first

The workaround turned out to be better than the thing it replaced, for reasons that have nothing to do with AppArmor.

mail() returns a boolean that means "the sendmail binary exited zero". That is not delivery, or acceptance, or queueing — it is the exit status of a subprocess. Speaking SMTP, I get the actual response code for every stage, so "connection refused", "mailbox unavailable" and "message accepted" are three distinguishable outcomes instead of one false. The form can honestly tell a visitor which one happened.

It also does not need a hardening exception. The AppArmor profile stays as narrow as the distribution shipped it, and the web application's answer to "can you execute a setgid binary" stays no — which is worth considerably more than the hour the workaround cost.

When a sandbox refuses something, the useful question is not how to widen the sandbox. It is why the sandbox thought that was worth refusing.

Correspondence

Write to the lab

Commissions, collaborations, or a quiet hello. The person you write to is the person who does the work — I reply within two working days.