CVE-2023-27395
A heap-based buffer overflow vulnerability exists in the vpnserver WpcParsePacket() functionality of SoftEther VPN 4.41-9782-beta, 5.01.9674 and 5.02. A specially crafted network packet can lead to arbitrary code execution. An attacker can perform a man-in-the-middle attack to trigger this vulnerability.
The versions below were either tested or verified to be vulnerable by Talos or confirmed to be vulnerable by the vendor.
SoftEther VPN 4.41-9782-beta
SoftEther VPN 5.01.9674
SoftEther VPN 5.02
While 5.01.9674 is a development version, it is distributed at the time of writing by Ubuntu and other Debian-based distributions.
SoftEther VPN - https://www.softether.org/
9.0 - CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H
CWE-122 - Heap-based Buffer Overflow
SoftEther is a multi-platform VPN project that provides both server and client code to connect over a variety of VPN protocols, including Wireguard, PPTP, SSTP, L2TP, etc. SoftEther has a variety of features for both enterprise and personal use, and enables Nat Traversal out-of-the-box for remote-access setups behind firewalls.
By default when SoftEther VPN server is run, the server registers itself with ddns.softether-network.net
, such that the VPN server can traverse NAT out-of-the-box and be reachable through firewalls immediately. A specific thread is designated for this task, and it will henceforth be referred to as the DDNS client thread due to the function name designating this code path:
// DDNS client thread
void DCThread(THREAD *thread, void *param)
{
DDNS_CLIENT *c;
INTERRUPT_MANAGER *interrupt;
// [...]
// Validate arguments
if (thread == NULL || param == NULL)
{
return;
}
c = (DDNS_CLIENT *)param;
// [...]
while (c->Halt == false){
// [...]
// IPv4 host registration
if (c->NextRegisterTick_IPv4 == 0 || now >= c->NextRegisterTick_IPv4)
{
UINT next_interval;
c->Err_IPv4 = DCRegister(c, false, NULL, NULL);
if (c->Err_IPv4 == ERR_NO_ERROR)
{
next_interval = GenRandInterval(DDNS_REGISTER_INTERVAL_OK_MIN, DDNS_REGISTER_INTERVAL_OK_MAX);
}
else
{
next_interval = GenRandInterval(DDNS_REGISTER_INTERVAL_NG_MIN, DDNS_REGISTER_INTERVAL_NG_MAX);
}
//next_interval = 0;
c->NextRegisterTick_IPv4 = Tick64() + (UINT64)next_interval;
if (true)
{
DDNS_CLIENT_STATUS st;
DCGetStatus(c, &st);
SiApplyAzureConfig(c->Cedar->Server, &st);
}
AddInterrupt(interrupt, c->NextRegisterTick_IPv4);
}
As long as the DDNS_CLIENT
object never has it’s Halt
flag set, we continuously loop through the different timers, checking to see if our public facing IP addresses have changed or not, so we know if we need to update the public SoftEther DDNS server’s settings for our hostname. This basic IP address checking is simple unencrypted traffic over UDP port 5004 and is not too interesting, but the other DDNS traffic that gets sent is also unencrypted and is a lot more useful. Continuing in DCThread:
// DDNS client thread
void DCThread(THREAD *thread, void *param)
{
// [...]
// IPv4 host registration
if (c->NextRegisterTick_IPv4 == 0 || now >= c->NextRegisterTick_IPv4)
{
UINT next_interval;
c->Err_IPv4 = DCRegister(c, false, NULL, NULL); // [1]
if (c->Err_IPv4 == ERR_NO_ERROR)
{
next_interval = GenRandInterval(DDNS_REGISTER_INTERVAL_OK_MIN, DDNS_REGISTER_INTERVAL_OK_MAX);
}
else
{
next_interval = GenRandInterval(DDNS_REGISTER_INTERVAL_NG_MIN, DDNS_REGISTER_INTERVAL_NG_MAX);
}
//next_interval = 0;
c->NextRegisterTick_IPv4 = Tick64() + (UINT64)next_interval;
if (true)
{
DDNS_CLIENT_STATUS st;
DCGetStatus(c, &st);
SiApplyAzureConfig(c->Cedar->Server, &st);
}
AddInterrupt(interrupt, c->NextRegisterTick_IPv4);
}
Assuming that our IP address has changed, or the timeout has occurred, or even if our server is just starting, we end up hitting the DCRegister
[1] function, which contains more of the actual network packet processing:
// Execution of registration
UINT DCRegister(DDNS_CLIENT *c, bool ipv6, DDNS_REGISTER_PARAM *p, char *replace_v6)
{
char *url;
char url2[MAX_SIZE];
char url3[MAX_SIZE];
PACK *req, *ret;
char key_str[MAX_SIZE];
UCHAR machine_key[SHA1_SIZE];
char machine_key_str[MAX_SIZE];
char machine_name[MAX_SIZE];
BUF *cert_hash = NULL;
UINT err = ERR_INTERNAL_ERROR;
UCHAR key_hash[SHA1_SIZE];
char key_hash_str[MAX_SIZE];
bool use_azure = false;
char current_azure_ip[MAX_SIZE];
INTERNET_SETTING t;
UINT build = 0;
char add_header_name[64];
char add_header_value[64];
// Validate arguments
if (c == NULL)
{
return ERR_INTERNAL_ERROR;
}
// [...]
Format(url2, sizeof(url2), "%s?v=%I64u", url, Rand64());
Format(url3, sizeof(url3), url2, key_hash_str[2], key_hash_str[3]);
ReplaceStr(url3, sizeof(url3), url3, "https://", "http://");
ReplaceStr(url3, sizeof(url3), url3, ".servers", ".open.servers");
cert_hash = StrToBin(DDNS_CERT_HASH);
Debug("WpcCall: %s\n", url3);
ret = WpcCallEx2(url3, &t, DDNS_CONNECT_TIMEOUT, DDNS_COMM_TIMEOUT, "register", req, // [2]
NULL, NULL, ((cert_hash != NULL && ((cert_hash->Size % SHA1_SIZE) == 0)) ? cert_hash->Buf : NULL),
(cert_hash != NULL ? cert_hash->Size / SHA1_SIZE : 0),
NULL, DDNS_RPC_MAX_RECV_SIZE, // dyn32, (128 * 1024 * 1024)...
add_header_name, add_header_value,
DDNS_SNI_VER_STRING);
Debug("WpcCall Ret: %u\n", ret);
When the DDNS timeout occurs, our IP address changes or the server restarts, we end up hitting the WpcCallEx2
function at [2], which sends an HTTP request, waits for the response and then appropriately parses said response into a PACK *
object. Continuing into WpcCallEx2
:
PACK *WpcCallEx2(char *url, INTERNET_SETTING *setting, UINT timeout_connect, UINT timeout_comm,
char *function_name, PACK *pack, X *cert, K *key, void *sha1_cert_hash, UINT num_hashes, bool *cancel, UINT max_recv_size,
char *additional_header_name, char *additional_header_value, char *sni_string)
{
URL_DATA data;
BUF *b, *recv;
UINT error;
WPC_PACKET packet;
// Validate arguments
if (function_name == NULL || pack == NULL)
{
return PackError(ERR_INTERNAL_ERROR);
}
if (ParseUrl(&data, url, true, NULL) == false)
{
return PackError(ERR_INTERNAL_ERROR);
}
PackAddStr(pack, "function", function_name);
b = WpcGeneratePacket(pack, cert, key);
if (b == NULL)
{
return PackError(ERR_INTERNAL_ERROR);
}
SeekBuf(b, b->Size, 0);
WriteBufInt(b, 0);
SeekBuf(b, 0, 0);
if (IsEmptyStr(additional_header_name) == false && IsEmptyStr(additional_header_value) == false)
{
StrCpy(data.AdditionalHeaderName, sizeof(data.AdditionalHeaderName), additional_header_name);
StrCpy(data.AdditionalHeaderValue, sizeof(data.AdditionalHeaderValue), additional_header_value);
}
if (sni_string != NULL && IsEmptyStr(sni_string) == false)
{
StrCpy(data.SniString, sizeof(data.SniString), sni_string);
}
recv = HttpRequestEx3(&data, setting, timeout_connect, timeout_comm, &error, // [3]
false, b->Buf, NULL, NULL, sha1_cert_hash, num_hashes, cancel, max_recv_size,
NULL, NULL);
FreeBuf(b);
if (recv == NULL)
{
return PackError(error);
}
if (WpcParsePacket(&packet, recv) == false) // [4]
{
FreeBuf(recv);
return PackError(ERR_PROTOCOL_ERROR);
}
FreeBuf(recv);
FreeX(packet.Cert);
return packet.Pack;
}
At [3], the HTTP request is actually sent and the response is returned into the BUF *recv
variable, which for all intents and purposes functions as a normal C++ std::string. This BUF *recv
is then passed into the actual processing function WpcParsePacket
at [4]:
// Parse the packet
bool WpcParsePacket(WPC_PACKET *packet, BUF *buf)
{
LIST *o;
BUF *b;
bool ret = false;
UCHAR hash[SHA1_SIZE];
// Validate arguments
if (packet == NULL || buf == NULL)
{
return false;
}
Zero(packet, sizeof(WPC_PACKET));
o = WpcParseDataEntry(buf); // [5]
b = WpcDataEntryToBuf(WpcFindDataEntry(o, "PACK")); // [6]
if (b != NULL)
{
Sha1(hash, b->Buf, b->Size);
packet->Pack = BufToPack(b);
FreeBuf(b);
if (packet->Pack != NULL)
{
BUF *b;
ret = true;
b = WpcDataEntryToBuf(WpcFindDataEntry(o, "HASH")); // [7]
The overall flow of this function ends up parsing the data at [5], searching for individual objects in the parsed data (like at [6] or [7]) and then parsing/validating the individual objects. Thankfully we do not have to go that far, so let us examine what exactly WpcParseDataEntry
does at [5]:
// Parse the data entry
LIST *WpcParseDataEntry(BUF *b)
{
char entry_name[WPC_DATA_ENTRY_SIZE]; // 4 bytes
char size_str[11];
LIST *o;
// Validate arguments
if (b == NULL)
{
return NULL;
}
SeekBuf(b, 0, 0);
o = NewListFast(NULL);
while (true)
{
UINT size;
WPC_ENTRY *e;
if (ReadBuf(b, entry_name, WPC_DATA_ENTRY_SIZE) != WPC_DATA_ENTRY_SIZE) // [8]
{
break;
}
Zero(size_str, sizeof(size_str));
if (ReadBuf(b, size_str, 10) != 10) // [9]
{
break;
}
size = ToInt(size_str); // [10]
if ((b->Size - b->Current) < size) // both 32 bit...
{
break;
}
e = ZeroMalloc(sizeof(WPC_ENTRY));
e->Data = (UCHAR *)b->Buf + b->Current; // [11]
Copy(e->EntryName, entry_name, WPC_DATA_ENTRY_SIZE);
e->Size = size;
SeekBuf(b, size, 1);
Add(o, e);
}
return o;
}
Finally, actual byte is read. Until we run out of bytes, we read four bytes [8] as the name of our current object (e.g. “PACK” or “HASH”), which is used further down the line as a tag/way to identify the particular data. Next at [9], ten bytes are read in as the size of the current object in ASCII (e.g. “PACK0000005550”). This size is compressed into the 32-bit integer size
at [10], and then at [11] we assign e->Data to point to the data of this current object. Very important to note, there is no new allocation for the data itself, and the pointer points into the BUF *b
object passed in. A quick note as well before stepping back up to WpcParsePacket
: the returned value of this function is a list of WPC_ENTRY *
objects that are as follows:
// WPC entry
struct WPC_ENTRY
{
char EntryName[WPC_DATA_ENTRY_SIZE]; // Entry name (4)
void *Data; // Data
UINT Size; // Data size
};
Continuing back up:
// Parse the packet
bool WpcParsePacket(WPC_PACKET *packet, BUF *buf)
{
LIST *o;
BUF *b;
bool ret = false;
UCHAR hash[SHA1_SIZE];
// Validate arguments
if (packet == NULL || buf == NULL)
{
return false;
}
Zero(packet, sizeof(WPC_PACKET));
o = WpcParseDataEntry(buf); // [5]
b = WpcDataEntryToBuf(WpcFindDataEntry(o, "PACK")); // [6]
if (b != NULL)
{
Now that we have our list of data entries (LIST *o
), we head into the WpcFindDataEntry
function at [6] to find the data entry with a name of “PACK”, and then we pass that WPC_ENTRY *
into WpcDataEntryToBuf
:
// Decode the buffer from WPC_ENTRY
BUF *WpcDataEntryToBuf(WPC_ENTRY *e)
{
void *data;
UINT data_size;
UINT size;
BUF *b;
// Validate arguments
if (e == NULL)
{
return NULL;
}
data_size = e->Size + 4096;
data = Malloc(data_size); // [12]
size = DecodeSafe64(data, e->Data, e->Size); // [13]
b = NewBuf();
WriteBuf(b, data, size);
SeekBuf(b, 0, 0);
Free(data);
return b;
}
At [12], we create a new allocation of e->Size + 0x1000
. At [13], we populate this new allocation with a base64 decoding of the data from our “PACK” input. For instance, if our HTTP response had data that looked like “PACK00000008AAAABBBB”, at this point we’d be base64 decoding the “AAAABBBB” string, and the data
buffer would be 0x1008 bytes long, just in case things go awry. Continuing into DecodeSafe64
:
// Decode from escaped Base64
UINT DecodeSafe64(void *dst, const char *src, UINT size)
{
if (dst == NULL || src == NULL)
{
return 0;
}
if (size == 0) // [14]
{
size = StrLen(src);
}
char *tmp = Malloc(size + 1);
Copy(tmp, src, size); // [15]
tmp[size] = '\0';
Safe64ToBase64(tmp, size);
const UINT ret = Base64Decode(dst, tmp, size); // [16]
Free(tmp);
return ret;
}
Interestingly, it is within DecodeSafe64
that we finally find hints of our vulnerability. At [15], we copy our input buffer into a newly allocated buffer of size size + 1
that gets null terminated, which is all well and good. At [16], we then Base64Decode
our input buffer into the dst
buffer that got passed in. If we look previously to WpcDataEntryToBuf
, we can see that dst
points to the data
allocation at [12]. While this might initially look sound due to the buffer sizes getting added with static values, a set of key factors lead to a critical failure.
We now present a specific input that will hit a fun set of conditions: "PACK00000000" + ("B" * 0x2000)
. By the time we get to WpcDataEntryToBuf
, our WPC_ENTRY
object is going to look as such:
// WPC entry
struct WPC_ENTRY
{
char EntryName[WPC_DATA_ENTRY_SIZE] = "PACK";
void *Data = ((BUF *)input)->Buffer; // BBBBBBBBB.... // [17]
UINT Size = 0x0;
};
Again, very important to note that no new allocation was assigned for WPC_ENTRY.Data
[17]; it still points to the “PACK” data in our input buffer object. So now let’s run our object through WpcDataEntryToBuf
:
// Decode the buffer from WPC_ENTRY
BUF *WpcDataEntryToBuf(WPC_ENTRY *e)
{
void *data;
UINT data_size;
UINT size;
BUF *b;
// Validate arguments
if (e == NULL)
{
return NULL;
}
data_size = e->Size + 4096;
data = Malloc(data_size); // [18]
size = DecodeSafe64(data, e->Data, e->Size); // [19]
b = NewBuf();
WriteBuf(b, data, size);
SeekBuf(b, 0, 0);
Free(data);
return b;
}
Our allocation at [18] ends up being 0x1000 bytes (0 + 0x1000), and we essentially call DecodeSafe64(data, e->Data, 0)
, since the size here is taken directly from our WPC_ENTRY
object. To continue:
// Decode from escaped Base64
UINT DecodeSafe64(void *dst, const char *src, UINT size)
{
if (dst == NULL || src == NULL)
{
return 0;
}
if (size == 0) // [19]
{
size = StrLen(src);
}
char *tmp = Malloc(size + 1);
Copy(tmp, src, size); // [20]
tmp[size] = '\0';
Safe64ToBase64(tmp, size);
const UINT ret = Base64Decode(dst, tmp, size); // [21]
Free(tmp);
return ret;
}
Since our input size is 0x0, we hit the branch at [19] and the size
variable is assigned as the Strlen(src)
. As repeatedly mentioned before e->Data
is b->Buf
is “BBBBBBB…” in our input buffer, which was never null terminated, never copied, nothing. As such, we control what the size
variable is (up to a limit of how long our string can be). The copy at [20] into the temporary buffer is still valid, since it’s actively using the size
variable. The same cannot be said for the Base64Decode
[21], as the dst
variable points to the data
buffer at [18], which we know is size 0x1000. This results in a classical user-controlled heap overflow, which would quickly lead to code execution.
Since this all happens over HTTP requests to remote addresses, which occur by default when the server is started, a man-in-the-middle attack is still needed to actively exploit. That is the only restriction on this vulnerability.
***********************************************************************************
***********************************************************************************
rax : 0x0 | rip[L] : 0x7ff83ba14bba <__memset_evex_unali
rbx : 0x7ff7e8019220 | eflags : 0x10202
rcx : 0x4407224 | cs : 0x33
rdx : 0x7ff7e8019220 | ss : 0x2b
rsi : 0x0 | ds : 0x0
rdi : 0x7ff7e8023000 | es : 0x0
rbp : 0x7ff7e8019210 | fs : 0x0
rsp : 0x7ff83a82c9d8 | gs : 0x0
r8 : 0x7ff7e80091c0 | k0 : 0xffffc00000000000
r9 : 0x0 | k1 : 0xffff
r10 : 0x7ff7e8019a00 | k2 : 0xf
r11 : 0xa8bea83248dfc15e | k3 : 0x0
r12 : 0x1800 | k4 : 0xffffffff
r13 : 0x7ff7e800c4ee | k5 : 0x0
r14 : 0x7ff7e8018200 | k6 : 0x0
r15 : 0x7ff7e8008de0 | k7 : 0x0
***********************************************************************************
0x7ff83ba14ba7 <__memset_evex_unaligned_erms+103>: nop WORD PTR [rax+rax*1+0x0]
0x7ff83ba14bb0 <__memset_evex_unaligned_erms+112>: movzx eax,sil
0x7ff83ba14bb4 <__memset_evex_unaligned_erms+116>: mov rcx,rdx
0x7ff83ba14bb7 <__memset_evex_unaligned_erms+119>: mov rdx,rdi
=> 0x7ff83ba14bba <__memset_evex_unaligned_erms+122>: rep stos BYTE PTR es:[rdi],al
0x7ff83ba14bbc <__memset_evex_unaligned_erms+124>: mov rax,rdx
0x7ff83ba14bbf <__memset_evex_unaligned_erms+127>: ret
0x7ff83ba14bc0 <__memset_evex_unaligned_erms+128>: cmp rdx,QWORD PTR [rip+0x69829] # 0x7ff83ba7e3f0 <__x86_rep_stosb_threshold>
0x7ff83ba14bc7 <__memset_evex_unaligned_erms+135>: ja 0x7ff83ba14bb0 <__memset_evex_unaligned_erms+112>
***********************************************************************************
#0 __memset_evex_unaligned_erms () at ../sysdeps/x86_64/multiarch/memset-vec-unaligned-erms.S:250
#1 0x00007ff83baf9e9b in Free (addr=0x7ff7e8019220) at/Memory.c:3623
#2 Free (addr=0x7ff7e8019220) at/Memory.c:3608
#3 0x00007ff83bcd70cd in DecodeSafe64 (size=?, src=0x7ff7e800c4ee 'B' <repeats 200 times>..., dst=0x7ff7e8018200) at/Wpc.c:1298
#4 DecodeSafe64 (dst=0x7ff7e8018200, src=0x7ff7e800c4ee 'B' <repeats 200 times>..., size=?) at/Wpc.c:1280
#5 0x00007ff83bcd7146 in WpcDataEntryToBuf (e=0x7ff7e80076d0) at/Wpc.c:317
#6 0x00007ff83bcd7235 in WpcParsePacket (buf=0x7ff7e8006b00, packet=0x7ff83a82cb30) at/Wpc.c:170
#7 WpcParsePacket (packet=0x7ff83a82cb30, buf=0x7ff7e8006b00) at/Wpc.c:154
#8 0x00007ff83bcd786e in WpcCallEx2 (url=url@entry=0x7ff83a82e9d0 "http://xx.xx.dev.open.servers-v6.ddns.softether-network.net/ddns/ddns.aspx?v=222222222222", setting=setting@entry=0x7ff83a82df80, timeout_connect=15000, timeout_comm=timeout_comm@entry=60000, function_name=function_name@entry=0x7ff83bce5eeb "register", pack=pack@entry=0x7ff7e80078c0, cert=0x0, key=0x0, sha1_cert_hash=0x7ff7e80097d0, num_hashes=5, cancel=0x0, max_recv_size=134217728, additional_header_name=0x7ff83a82e6d0 "", additional_header_value=0x7ff83a82e710 "", sni_string=0x7ff83bce5dbe "DDNS") at/Wpc.c:127
#9 0x00007ff83bc4b42d in DCRegister (c=c@entry=0x5603381d4c90, ipv6=ipv6@entry=true, p=p@entry=0x0, replace_v6=replace_v6@entry=0x0) at/DDNS.c:556
#10 0x00007ff83bc4bcce in DCThread (param=0x5603381d4c90, thread=?) at/DDNS.c:346
#11 0x00007ff83baf443d in ThreadPoolProc (param=0x5603381e4900, t=0x5603381d6800) at/Kernel.c:872
#12 ThreadPoolProc (t=0x5603381d6800, param=0x5603381e4900) at/Kernel.c:827
#13 0x00007ff83bb307d1 in UnixDefaultThreadProc (param=0x5603381d6a90) at/Unix.c:1594
#14 0x00007ff83b8f9b43 in start_thread (arg=?) at ./nptl/pthread_create.c:442
#15 0x00007ff83b98ba00 in clone3 () at ../sysdeps/unix/sysv/linux/x86_64/clone3.S:81
***********************************************************************************
The vendor issued an advisory: https://www.softether.org/9-about/News/904-SEVPN202301
2023-04-03 - Vendor Disclosure
2023-04-03 - Initial Vendor Contact
2023-06-30 - Vendor Patch Release
2023-10-12 - Public Release
Discovered by Lilith >_> of Cisco Talos.