CVE-2023-22325
A denial of service vulnerability exists in the DCRegister DDNS_RPC_MAX_RECV_SIZE functionality of SoftEther VPN 4.41-9782-beta, 5.01.9674 and 5.02. A specially crafted network packet can lead to denial of service. 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/
5.9 - CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H
CWE-835 - Loop with Unreachable Exit Condition (‘Infinite Loop’)
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 its 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);
FreeBuf(cert_hash);
FreePack(req);
err = GetErrorFromPack(ret);
ExtractAndApplyDynList(ret); // [3]
We can ignore most of the initialization code. For our purposes, we only really care that the WpcCallEx2
function reaches out to a DNS name like xc.xi.dev.open.servers.ddns.softether-network.net
and sends an unencrypted UDP request that asks for a NAT traversal token looking like so:
0000 00 00 00 03 00 00 00 07 6f 70 63 6f 64 65 00 00 ........opcode..
0010 00 02 00 00 00 01 00 00 00 09 67 65 74 5f 74 6f ..........get_to
0020 6b 65 6e 00 00 00 08 74 72 61 6e 5f 69 64 00 00 ken....tran_id..
0030 00 04 00 00 00 01 11 55 11 bb aa 87 e8 55 00 00 ................
0040 00 16 6e 61 74 5f 74 72 61 76 65 72 73 61 6c 5f ..nat_traversal_
0050 76 65 72 73 69 6f 6e 00 00 00 00 00 00 00 01 00 version.........
0060 00 00 01 ...
Before going further we must briefly describe the simple TLV protocol format here, which looks like:
struct packed_item {
size_t namelen;
char name[namelen+1];
uint32_t value_type; // [4]
// value/data
}
struct packed_buffer {
uint32_t number_of_items;
struct packed_item[number_of_items];
}
The value types for each item in the buffer [4] can be VALUE_INT
, VALUE_DATA
, VALUE_STR
, VALUE_UNISTR
or VALUE_INT64
, which is then followed immediately by the item itself. Their size and format is dependent on the type. Regardless, this packed buffer packet data is from the response to WpcCallEx2
[2], which then gets fed into the ExtractAndApplyDynList
function at [3]:
// Apply by extracting dynamic value list from the specified PACK
void ExtractAndApplyDynList(PACK *p)
{
BUF *b;
// Validate arguments
if (p == NULL)
{
return;
}
b = PackGetBuf(p, "DynList"); // [4]
if (b == NULL)
{
return;
}
AddDynList(b); // [5]
FreeBuf(b);
}
This function tries to unpack the DynList
item from the response buffer as another nested packed buffer and then passes this validated buffer into AddDynList
[5]:
// Insert the data to the dynamic value list
void AddDynList(BUF *b)
{
PACK *p;
TOKEN_LIST *t;
// Validate arguments
if (b == NULL)
{
return;
}
SeekBufToBegin(b);
p = BufToPack(b); // [6]
if (p == NULL)
{
return;
}
t = GetPackElementNames(p);
if (t != NULL)
{
UINT i;
for (i = 0; i < t->NumTokens; i++) // [7]
{
char *name = t->Token[i];
UINT64 v = PackGetInt64(p, name);
SetDynListValue(name, v); // [8]
}
FreeToken(t);
}
FreePack(p);
}
At [6], the input buffer (which is essentially a C++ std::string) is parsed to make sure it follows the “packed_buffer” format as mentioned above. It then walks the names of each of the items at [7] and passes the name and value (assuming it’s a UINT64) into the SetDynListValue
function at [8]:
// Set the value to the dynamic value list
void SetDynListValue(char *name, UINT64 value)
{
// Validate arguments
if (name == NULL)
{
return;
}
if (g_dyn_value_list == NULL)
{
return;
}
LockList(g_dyn_value_list);
{
UINT i;
DYN_VALUE *v = NULL;
for (i = 0; i < LIST_NUM(g_dyn_value_list); i++)
{
DYN_VALUE *vv = LIST_DATA(g_dyn_value_list, i); // [9]
if (StrCmpi(vv->Name, name) == 0)
{
v = vv;
break;
}
}
if (v == NULL)
{
v = ZeroMalloc(sizeof(DYN_VALUE)); // DYN_VALUE == { char[256], uint64_t }
StrCpy(v->Name, sizeof(v->Name), name);
Add(g_dyn_value_list, v);
}
v->Value = value;
}
UnlockList(g_dyn_value_list);
}
Finally getting somewhat to the core of the matter, SetDynListValue
safely walks the g_dyn_value_list
global list to see if the name matches any existing items. If so, we replace the value. If not, we allocate a new item and add it to the list. But this begs the questions of “what is in the g_dyn_value_list
?” and “what are these items used for?”. The only place values are read out of this list is via the DYN64
macro as follows:
#define DYN32(id, default_value) (UINT)DYN64(id, (UINT)default_value)
#define DYN64(id, default_value) ( (UINT64)GetDynValueOrDefaultSafe ( #id , (UINT64)( default_value )))
Which then points us to GetDynValueOrDefaultSafe
:
UINT64 GetDynValueOrDefaultSafe(char *name, UINT64 default_value)
{
return GetDynValueOrDefault(name, default_value, default_value / (UINT64)5, default_value * (UINT64)50);
}
This call to DYN32
is used in a number of places that all deal with either NAT-T or DDNS, but to save bandwidth, here are just the DDNS occurrences:
Cedar/DDNS.h:#define DDNS_RPC_MAX_RECV_SIZE DYN32(DDNS_RPC_MAX_RECV_SIZE, (128 * 1024 * 1024)) // [10]
Cedar/DDNS.h:#define DDNS_CONNECT_TIMEOUT DYN32(DDNS_CONNECT_TIMEOUT, (15 * 1000))
Cedar/DDNS.h:#define DDNS_COMM_TIMEOUT DYN32(DDNS_COMM_TIMEOUT, (60 * 1000))
Cedar/DDNS.h:#define DDNS_REGISTER_INTERVAL_OK_MIN DYN32(DDNS_REGISTER_INTERVAL_OK_MIN, (1 * 60 * 60 * 1000))
Cedar/DDNS.h:#define DDNS_REGISTER_INTERVAL_OK_MAX DYN32(DDNS_REGISTER_INTERVAL_OK_MAX, (2 * 60 * 60 * 1000))
Cedar/DDNS.h:#define DDNS_REGISTER_INTERVAL_NG_MIN DYN32(DDNS_REGISTER_INTERVAL_NG_MIN, (1 * 60 * 1000))
Cedar/DDNS.h:#define DDNS_REGISTER_INTERVAL_NG_MAX DYN32(DDNS_REGISTER_INTERVAL_NG_MAX, (5 * 60 * 1000))
Cedar/DDNS.h:#define DDNS_GETMYIP_INTERVAL_OK_MIN DYN32(DDNS_GETMYIP_INTERVAL_OK_MIN, (10 * 60 * 1000))
Cedar/DDNS.h:#define DDNS_GETMYIP_INTERVAL_OK_MAX DYN32(DDNS_GETMYIP_INTERVAL_OK_MAX, (20 * 60 * 1000))
Cedar/DDNS.h:#define DDNS_GETMYIP_INTERVAL_NG_MIN DYN32(DDNS_GETMYIP_INTERVAL_NG_MIN, (1 * 60 * 1000))
Cedar/DDNS.h:#define DDNS_GETMYIP_INTERVAL_NG_MAX DYN32(DDNS_GETMYIP_INTERVAL_NG_MAX, (5 * 60 * 1000))
Cedar/DDNS.h:#define DDNS_VPN_AZURE_CONNECT_ERROR_DDNS_RETRY_TIME_DIFF DYN32(DDNS_VPN_AZURE_CONNECT_ERROR_DDNS_RETRY_TIME_DIFF, (120 * 1000))
Cedar/DDNS.h:#define DDNS_VPN_AZURE_CONNECT_ERROR_DDNS_RETRY_TIME_DIFF_MAX DYN32(DDNS_VPN_AZURE_CONNECT_ERROR_DDNS_RETRY_TIME_DIFF_MAX, (10 * 60 * 1000))
Out of all of these, we’ve actually already seen one before in the source above: DDNS_RPC_MAX_RECV_SIZE
[10] up in the DCRegister
function:
// Execution of registration
UINT DCRegister(DDNS_CLIENT *c, bool ipv6, DDNS_REGISTER_PARAM *p, char *replace_v6)
{
// [...]
ret = WpcCallEx2(url3, &t, DDNS_CONNECT_TIMEOUT, DDNS_COMM_TIMEOUT, "register", req, // okay, at least 1 bug, maybe 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);
// [...]
/*
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)
*/
The DDNS_RPC_MAX_RECV_SIZE
dynamic list item is used as the UINT max_recv_size
inside of WpcCallEx2
, so following exactly how that parameter is used:
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)
{
// [...]
recv = HttpRequestEx3(&data, setting, timeout_connect, timeout_comm, &error,
false, b->Buf, NULL, NULL, sha1_cert_hash, num_hashes, cancel, max_recv_size, // [11]
NULL, NULL);
It gets eventually passed into the HttpRequestEx3
function at [11] and used as follows:
BUF *HttpRequestEx3(URL_DATA *data, INTERNET_SETTING *setting,
UINT timeout_connect, UINT timeout_comm,
UINT *error_code, bool check_ssl_trust, char *post_data,
WPC_RECV_CALLBACK *recv_callback, void *recv_callback_param, void *sha1_cert_hash, UINT num_hashes,
bool *cancel, UINT max_recv_size, char *header_name, char *header_value)
{
/// [...]
CONT:
// Receive
h = RecvHttpHeader(s); // [12]
if (h == NULL)
{
Disconnect(s);
ReleaseSock(s);
*error_code = ERR_DISCONNECTED;
return NULL;
}
http_error_code = 0;
if (StrLen(h->Method) == 8)
{
if (Cmp(h->Method, "HTTP/1.", 7) == 0)
{
http_error_code = ToInt(h->Target);
}
}
*error_code = ERR_NO_ERROR;
switch (http_error_code) { ... }
// [...]
// Get the length of the content
content_len = GetContentLength(h); //
if (max_recv_size != 0)
{
content_len = MIN(content_len, max_recv_size); // [13]
}
FreeHttpHeader(h);
socket_buffer = Malloc(socket_buffer_size); // 64000
// Receive the content
recv_buf = NewBuf();
while (true)
{
UINT recvsize = MIN(socket_buffer_size, content_len - recv_buf->Size); // [14]
UINT size;
if (recv_callback != NULL) // generally null
{
if (recv_callback(recv_callback_param,
content_len, recv_buf->Size, recv_buf) == false)
{
// Cancel the reception
*error_code = ERR_USER_CANCEL;
goto RECV_CANCEL;
}
}
if (recvsize == 0)
{
break;
}
size = Recv(s, socket_buffer, recvsize, s->SecureMode); // [15]
if (size == 0)
{
// Disconnected
*error_code = ERR_DISCONNECTED;
RECV_CANCEL:
FreeBuf(recv_buf);
Free(socket_buffer);
Disconnect(s);
ReleaseSock(s);
return NULL;
}
WriteBuf(recv_buf, socket_buffer, size); // [16]
}
After sending an inconsequential HTTP request unencrypted above [12], we finally see our max_recv_size
at [13], as it is used to determine the content_len
of the response. After this, we just keep receiving bytes at [15] until we have read in content_len - recv_buf->Size
bytes [14]. After receiving the bytes, they are written from the socket_buffer
into the Buffer *recv_buf
at [16], which essentially acts as an std::string
. But how many bytes can this buffer actually hold? Normally these Buffer *
objects will keep doubling in size every time they reach capacity inside WriteBuf
:
// Adjusting the buffer size
void AdjustBufSize(BUF *b, UINT new_size)
{
// Validate arguments
if (b == NULL)
{
return;
}
if (b->SizeReserved >= new_size)
{
return;
}
while (b->SizeReserved < new_size) // [17]
{
b->SizeReserved = b->SizeReserved * 2;
}
b->Buf = ReAlloc(b->Buf, b->SizeReserved);
// KS
KS_INC(KS_ADJUST_BUFSIZE_COUNT);
}
Curiously, the doubling continuously occurs inside of the while loop at [17], presumably in case the new_size
is significantly larger. Regardless, this design decision will become important soon. Another point to note about AdjustBufSize
is that both b->SizeReserved
and new_size
are UINT
sized, i.e. 32-bits. Also, since every buffer starts out with 0x2800 bytes for b->SizeReserved
, if new_size
is ever greater than 0x80000000, then the while statement at [17] turns into an infinite loop since (0x2800 << 20) == 0x80000000
. If we hit the loop one more time, b->SizeReserved
overflows to 0x1, restarting the whole process.
With the vulnerability explained, we have to quickly figure out how to get a new_size
that’s greater than 0x80000000. This problem turns out to be a lot simpler than expected, as we simply need to keep expanding a buffer to reach this point. Thus, if we remember from [13], content_len = MIN(content_len, max_recv_size);
, so the minimum of the content_len
and max_recv_size
is how large our resulting buffer is. Content length is the easier one, since it’s simply read in from the HTTP response’s headers. To get our max_recv_size
where we need it, we have to set the DDNS_RPC_MAX_RECV_SIZE
global dynamic list variable via a different request (this can be achieved via RUDPProcess_NatT_Recv
UDP traffic, also MITM’able). Since DYN64
ends up eventually resulting in ` GetDynValueOrDefault(name, default_value, default_value / (UINT64)5, default_value * (UINT64)50); as listed above, and also because the default
DDNS_RPC_MAX_RECV_SIZE is
128 * 1024 * 1024 (0x8000000), any value we assign to
DDNS_RPC_MAX_RECV_SIZE that is over 0x8000000 will result in
128 * 1024 * 1024 * 50` (0x190000000) being returned as the value (since it’s the ‘safe’ upper-bound), which is over the 0x80000000 limit that we need to cause the infinite loop. Even though 0x190000000 gets truncated to 0x90000000 due to UINT32 variables being used, we get lucky that 0x90000000 is still greater than 0x80000000.
Thus, in summary, we assign DDNS_RPC_MAX_RECV_SIZE
to anything over 0x8000000 via the unencrypted NAT-T UDP traffic. We then respond to an unencrypted HTTP request with an HTTP response that has a Content-Length greater than 0x80000000. Finally, we have to send at least 0x80000000 bytes for our buffer to expand, resulting in an infinite loop and disabling of SoftEther’s DDNS thread. While only denying service to a single thread, the server would become inaccessible through NAT or firewalls, denying one of the major use-cases of this product.
Thread 26 (Thread 0x7f861c049380 (LWP 8103) "vpnserver"):
#0 AdjustBufSize (b=0x7f85b800f680, new_size=2684416000) at /softether/SoftEtherVPN_orig/src/Mayaqua/Memory.c:3117
#1 0x00007f861d3e8978 in WriteBuf (size=64000, buf=0x7f85b8015eb0, b=0x7f85b800f680) at /softether/SoftEtherVPN_orig/src/Mayaqua/Memory.c:2758
#2 WriteBuf (b=0x7f85b800f680, buf=0x7f85b8015eb0, size=64000) at /softether/SoftEtherVPN_orig/src/Mayaqua/Memory.c:2745
#3 0x00007f861d5c393a in HttpRequestEx3 (data=data@entry=0x7f861c042be0, setting=setting@entry=0x7f861c043f80, timeout_connect=timeout_connect@entry=15000, timeout_comm=timeout_comm@entry=60000, error_code=error_code@entry=0x7f861c042b2c, check_ssl_trust=check_ssl_trust
@entry=false, post_data=0x7f85b8013690 "PACK0000"..., recv_callback=0x0, recv_c
allback_param=0x0, sha1_cert_hash=<optimized out>, num_hashes=<optimized out>, cancel=0x0, max_recv_size=4294967295, header_name=0x0, header_value=0x0) at /softether/SoftEtherVPN_orig/src/Cedar/Wpc.c:966
#4 0x00007f861d5c4849 in WpcCallEx2 (url=url@entry=0x7f861c0449d0 "http://xx.xx.dev.open.servers-v6.ddns.softether-network.net/ddns/ddns.aspx?v=22222222222", setting=setting@entry=0x7f861c043f80, timeout_connect=15000, timeout_comm=timeout_comm@entry=60000, func
tion_name=function_name@entry=0x7f861d5d2eeb "register", pack=pack@entry=0x7f85b8007b90, cert=0x0, key=0x0, sha1_cert_hash=0x7f85b800c820, num_hashes=5, cancel=0x0, max_recv_size=4294967295, additional_header_name=0x7f861c0446d0 "", additional_header_value=0x7f861c044710 "", sni_string=0x7f861d5d2dbe "DDNS") at /softether/SoftEtherVPN_orig/src/Cedar/Wpc.c:116
#5 0x00007f861d53842d in DCRegister (c=c@entry=0x5615c7e8f2d0, ipv6=ipv6@entry=true, p=p@entry=0x0, replace_v6=replace_v6@entry=0x0) at /softether/SoftEtherVPN_orig/src/Cedar/DDNS.c:556
#6 0x00007f861d538cce in DCThread (param=0x5615c7e8f2d0, thread=<optimized out>) at /softether/SoftEtherVPN_orig/src/Cedar/DDNS.c:346
#7 0x00007f861d3e143d in ThreadPoolProc (param=0x5615c7e86bb0, t=0x5615c7e8edd0) at /softether/SoftEtherVPN_orig/src/Mayaqua/Kernel.c:872
#8 ThreadPoolProc (t=0x5615c7e8edd0, param=0x5615c7e86bb0) at /softether/SoftEtherVPN_orig/src/Mayaqua/Kernel.c:827
#9 0x00007f861d41d7d1 in UnixDefaultThreadProc (param=0x5615c7e8f060) at /softether/SoftEtherVPN_orig/src/Mayaqua/Unix.c:1594
#10 0x00007f861d094b43 in start_thread (arg=<optimized out>) at ./nptl/pthread_create.c:442
#11 0x00007f861d126a00 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.