Talos Vulnerability Report

TALOS-2023-1736

SoftEther VPN DCRegister DDNS_RPC_MAX_RECV_SIZE denial of service vulnerability

October 12, 2023
CVE Number

CVE-2023-22325

SUMMARY

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.

CONFIRMED VULNERABLE VERSIONS

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.

PRODUCT URLS

SoftEther VPN - https://www.softether.org/

CVSSv3 SCORE

5.9 - CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H

CWE

CWE-835 - Loop with Unreachable Exit Condition (‘Infinite Loop’)

DETAILS

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.

Crash Information

 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
VENDOR RESPONSE

The vendor issued an advisory: https://www.softether.org/9-about/News/904-SEVPN202301

TIMELINE

2023-04-03 - Vendor Disclosure
2023-04-03 - Initial Vendor Contact
2023-06-30 - Vendor Patch Release
2023-10-12 - Public Release

Credit

Discovered by Lilith >_> of Cisco Talos.