]> git.ipfire.org Git - thirdparty/openvpn.git/commitdiff
openvpnserv: fix DNS SearchList state pollution on (dis)connect
authorLev Stipakov <lev@openvpn.net>
Fri, 22 May 2026 09:05:43 +0000 (12:05 +0300)
committerGert Doering <gert@greenie.muc.de>
Wed, 1 Jul 2026 07:49:29 +0000 (09:49 +0200)
RemoveDnsSearchDomains used wcsstr on the comma-separated SearchList,
so removing a VPN-pushed domain like "vpn.corp.local" could match
inside an unrelated pre-existing entry like "test.vpn.corp.local"
and corrupt it. The wmemmove length was also too short, truncating
the tail of the list when the removed segment was shorter than what
followed. The wcsncmp against InitialSearchList was prefix-only too,
so a shortened list that happened to be a prefix of the initial one
triggered a spurious reset.

Replace the manual cut with two pure helpers that are unit-tested in
test_openvpnserv.c:

* AppendSearchList just appends the requested string, with a separator
  comma if the list is non-empty. No deduplication: pushed domains are
  always appended verbatim.

* RemoveSearchListTokens uses multiset semantics. For each comma-
  separated token in the undo string it finds the last remaining
  token in the list that fully matches (no substring) and splices it
  out in place. If a domain appears K times in the list and L times in
  the undo set, min(K, L) instances are removed.

Together these properties give the right behavior under overlap:

  user has "corp.local"; VPN pushes "corp.local"
    -> SearchList = "corp.local,corp.local"
    -> on disconnect one occurrence is removed, user's entry preserved.

  session A pushes "openvpn.com"; session B pushes "openvpn.com"
    -> SearchList = "openvpn.com,openvpn.com"
    -> A disconnects: one removed, B still sees its domain.
    -> B disconnects: list returns to original.

AddDnsSearchDomains and RemoveDnsSearchDomains are now thin wrappers
around the helpers: read SearchList, call the helper, write back,
plus the StoreInitialDnsSearchList / ResetDnsSearchDomains dance.

CVE: 2026-13379
Reported-By: 章鱼哥 (www.aipyaipy.com)
GitHub: OpenVPN/openvpn-private-issues#123
GitHub: OpenVPN/openvpn-private-issues#124

Change-Id: Idad2b3662e98541c916e630c2f55108380cc432f
Signed-off-by: Lev Stipakov <lev@openvpn.net>
Acked-by: Heiko Hund <heiko@openvpn.net>
src/openvpnserv/interactive.c
tests/unit_tests/openvpnserv/test_openvpnserv.c

index 516a46914368643a37d205f975dfaead05efe461..30cfb6a5ae0a225efa0e788f8444981287ea4e38 100644 (file)
@@ -1444,24 +1444,149 @@ StoreInitialDnsSearchList(HKEY key, PCWSTR list)
 }
 
 /**
- * Append domain suffixes to an existing search list
+ * Append a comma-separated list of domains to another comma-separated
+ * list, in place. No deduplication: if a domain in @p add is already
+ * present in @p list it is appended again.
+ *
+ * @param  list      mutable wide string holding the current list (must
+ *                   be NUL-terminated). Modified on success.
+ * @param  list_cap  capacity of @p list in wide characters, including
+ *                   the NUL terminator.
+ * @param  add       comma-separated list of domains to append.
+ *
+ * @return TRUE on success, FALSE if appending would exceed @p list_cap
+ *         (in which case @p list is left unchanged).
+ */
+static BOOL
+AppendSearchList(PWSTR list, size_t list_cap, PCWSTR add)
+{
+    size_t list_len = wcslen(list);
+    size_t add_len = wcslen(add);
+    if (add_len == 0)
+    {
+        return TRUE;
+    }
+
+    size_t sep_len = (list_len > 0) ? 1 : 0;
+    if (list_len + sep_len + add_len + 1 > list_cap)
+    {
+        return FALSE;
+    }
+
+    if (sep_len)
+    {
+        list[list_len++] = L',';
+    }
+    wmemcpy(list + list_len, add, add_len + 1);
+    return TRUE;
+}
+
+/**
+ * Remove tokens from a comma-separated search list with multiset
+ * semantics: for each comma-separated token in @p remove, splice the
+ * last remaining matching token out of @p list. Comparison is on full
+ * tokens (case-sensitive). Empty tokens in @p remove are skipped.
+ *
+ * Removal targets the last occurrence to mirror AppendSearchList, which
+ * always appends. This keeps any pre-existing prefix intact, so once
+ * every pushed domain has been removed the list can again match the
+ * stored initial snapshot and trigger a full reset.
+ *
+ * Concretely, if a domain appears K times in @p list and L times in
+ * @p remove, min(K, L) instances are removed. That is the property that
+ * lets multiple VPN sessions, or a VPN and a pre-existing user entry,
+ * share a domain without one side's disconnect deleting the other
+ * side's claim on it.
+ *
+ * @param  list    mutable wide string holding the current list.
+ *                 Modified in place.
+ * @param  remove  comma-separated list of tokens to delete.
+ *
+ * @return number of tokens actually removed from @p list.
+ */
+static size_t
+RemoveSearchListTokens(PWSTR list, PCWSTR remove)
+{
+    size_t removed = 0;
+    PCWSTR domain = remove;
+    while (*domain)
+    {
+        PCWSTR comma = wcschr(domain, L',');
+        size_t domain_len = comma ? (size_t)(comma - domain) : wcslen(domain);
+        if (domain_len > 0)
+        {
+            /* Find the last token in @p list that exactly equals @p domain. */
+            PWSTR match = NULL;
+            PWSTR match_end = NULL;
+            for (PWSTR p = list; *p;)
+            {
+                PWSTR tok_end = wcschr(p, L',');
+                size_t tok_len = tok_end ? (size_t)(tok_end - p) : wcslen(p);
+                if (tok_len == domain_len && wcsncmp(p, domain, domain_len) == 0)
+                {
+                    match = p;
+                    match_end = tok_end;
+                }
+                if (!tok_end)
+                {
+                    break;
+                }
+                p = tok_end + 1;
+            }
+            if (match)
+            {
+                /* Splice the token out, eating its leading comma if it has
+                 * one, otherwise its trailing comma. */
+                PWSTR cut_start, cut_end;
+                if (match == list)
+                {
+                    cut_start = match;
+                    cut_end = match_end ? match_end + 1 : match + domain_len;
+                }
+                else
+                {
+                    cut_start = match - 1;
+                    cut_end = match + domain_len;
+                }
+                wmemmove(cut_start, cut_end, wcslen(cut_end) + 1);
+                removed++;
+            }
+        }
+        if (!comma)
+        {
+            break;
+        }
+        domain = comma + 1;
+    }
+    return removed;
+}
+
+/**
+ * Append domain suffixes to an existing search list.
+ *
+ * Pushed domains are always appended -- including ones that already
+ * appear in the list. Paired with the multiset-aware
+ * RemoveDnsSearchDomains, this lets overlapping VPN sessions (or VPN
+ * vs. pre-existing user entries) refcount their entries naturally:
+ * each Add records one occurrence, each Remove consumes one.
  *
  * @param  key          HKEY the list is stored at
- * @param  have_list    BOOL to indicate if a search list already exists
- * @param  domains      domain suffixes as comma separated string
+ * @param  have_list    BOOL indicating whether the key already has a
+ *                      non-empty SearchList value
+ * @param  domains      domain suffixes to append, comma-separated
  *
- * @return BOOL to indicate success or failure
+ * @return BOOL indicating success or failure
  */
 static BOOL
 AddDnsSearchDomains(HKEY key, BOOL have_list, PCWSTR domains)
 {
-    LSTATUS err;
     WCHAR list[2048] = { 0 };
-    DWORD size = sizeof(list);
 
     if (have_list)
     {
-        err = RegGetValueW(key, NULL, L"SearchList", RRF_RT_REG_SZ, NULL, list, &size);
+        DWORD size = sizeof(list);
+        LSTATUS err =
+            RegGetValueW(key, NULL, L"SearchList", RRF_RT_REG_SZ, NULL, list, &size);
         if (err)
         {
             MsgToEventLog(M_SYSERR, L"%S: could not get SearchList from registry (%lu)", __func__,
@@ -1473,28 +1598,16 @@ AddDnsSearchDomains(HKEY key, BOOL have_list, PCWSTR domains)
         {
             return FALSE;
         }
-
-        size_t listlen = (size / sizeof(list[0])) - 1; /* returned size is in bytes */
-        size_t domlen = wcslen(domains);
-        if (listlen + domlen + 2 > _countof(list))
-        {
-            MsgToEventLog(M_SYSERR, L"%S: not enough space in list for search domains (len=%lu)",
-                          __func__, domlen);
-            return FALSE;
-        }
-
-        /* Append to end of the search list */
-        PWSTR pos = list + listlen;
-        *pos = ',';
-        wcsncpy(pos + 1, domains, domlen + 1);
     }
-    else
+
+    if (!AppendSearchList(list, _countof(list), domains))
     {
-        wcsncpy(list, domains, wcslen(domains) + 1);
+        MsgToEventLog(M_SYSERR, L"%S: not enough space in list for search domains", __func__);
+        return FALSE;
     }
 
-    size = RegWStringSize(list);
-    err = RegSetValueExW(key, L"SearchList", 0, REG_SZ, (PBYTE)list, size);
+    DWORD size = RegWStringSize(list);
+    LSTATUS err = RegSetValueExW(key, L"SearchList", 0, REG_SZ, (PBYTE)list, size);
     if (err)
     {
         MsgToEventLog(M_SYSERR, L"%S: could not set SearchList to registry (%lu)", __func__, err);
@@ -1550,57 +1663,54 @@ out:
 }
 
 /**
- * Remove domain suffixes from an existing search list
+ * Remove domain suffixes from an existing search list.
+ *
+ * Delegates to RemoveSearchListTokens for the actual string surgery,
+ * which uses multiset semantics: each token in @p domains drops one
+ * occurrence in the registry SearchList. This means VPN sessions never
+ * delete more entries than they actually added, even when they pushed
+ * a domain that was already in the pre-existing list or in another
+ * session's add. Comparison is on full tokens, so unrelated entries
+ * that happen to contain a removed name as a substring are preserved.
  *
  * @param  key      HKEY the list is stored at
- * @param  domains  domain suffixes to remove as comma separated string
+ * @param  domains  domain suffixes to remove, comma-separated
  */
 static void
 RemoveDnsSearchDomains(HKEY key, PCWSTR domains)
 {
-    LSTATUS err;
     WCHAR list[2048];
     DWORD size = sizeof(list);
-
-    err = RegGetValueW(key, NULL, L"SearchList", RRF_RT_REG_SZ, NULL, list, &size);
+    LSTATUS err = RegGetValueW(key, NULL, L"SearchList", RRF_RT_REG_SZ, NULL, list, &size);
     if (err)
     {
         MsgToEventLog(M_SYSERR, L"%S: could not get SearchList from registry (%lu)", __func__, err);
         return;
     }
 
-    PWSTR dst = wcsstr(list, domains);
-    if (!dst)
+    if (RemoveSearchListTokens(list, domains) == 0)
     {
         MsgToEventLog(M_ERR, L"%S: could not find domains in search list", __func__);
         return;
     }
 
-    /* Cut out domains from list */
-    size_t domlen = wcslen(domains);
-    PCWSTR src = dst + domlen;
-    /* Also remove the leading comma, if there is one */
-    dst = dst > list ? dst - 1 : dst;
-    wmemmove(dst, src, domlen);
-
-    size_t list_len = wcslen(list);
-    if (list_len)
+    if (list[0] != L'\0')
     {
-        /* Now check if the shortened list equals the initial search list */
+        /* If the shortened list equals the snapshot we took at first
+         * touch, the user's pre-VPN state is fully restored -- wipe both
+         * SearchList and InitialSearchList. */
         WCHAR initial[2048];
         size = sizeof(initial);
         err = RegGetValueW(key, NULL, L"InitialSearchList", RRF_RT_REG_SZ, NULL, initial, &size);
-        if (err)
+        if (!err && wcscmp(list, initial) == 0)
         {
-            MsgToEventLog(M_SYSERR, L"%S: could not get InitialSearchList from registry (%lu)",
-                          __func__, err);
+            ResetDnsSearchDomains(key);
             return;
         }
-
-        /* If the search list is back to its initial state reset it */
-        if (wcsncmp(list, initial, list_len) == 0)
+        if (err && err != ERROR_FILE_NOT_FOUND)
         {
-            ResetDnsSearchDomains(key);
+            MsgToEventLog(M_SYSERR, L"%S: could not get InitialSearchList from registry (%lu)",
+                          __func__, err);
             return;
         }
     }
index e432f44dbddd24bdd6974c47aab8d456ecb8e4b2..e01b0e36287006ad934feb363cf61845ad677ba1 100644 (file)
@@ -153,6 +153,114 @@ test_convert_itf_dns_domains(void **state)
     assert_int_equal(err, ERROR_MORE_DATA);
 }
 
+static void
+test_append_search_list(void **state)
+{
+    WCHAR list[64];
+
+    /* append to an empty list */
+    wcscpy(list, L"");
+    assert_true(AppendSearchList(list, _countof(list), L"a.com"));
+    assert_int_equal(wcscmp(list, L"a.com"), 0);
+
+    /* append to a non-empty list */
+    wcscpy(list, L"x.com");
+    assert_true(AppendSearchList(list, _countof(list), L"a.com,b.com"));
+    assert_int_equal(wcscmp(list, L"x.com,a.com,b.com"), 0);
+
+    /* appending an empty string is a no-op */
+    wcscpy(list, L"x.com");
+    assert_true(AppendSearchList(list, _countof(list), L""));
+    assert_int_equal(wcscmp(list, L"x.com"), 0);
+
+    /* duplicates are allowed -- relied on by the multiset-aware remove */
+    wcscpy(list, L"x.com");
+    assert_true(AppendSearchList(list, _countof(list), L"x.com"));
+    assert_int_equal(wcscmp(list, L"x.com,x.com"), 0);
+
+    /* overflow: the list is left unchanged */
+    WCHAR tiny[8];
+    wcscpy(tiny, L"abc");
+    assert_false(AppendSearchList(tiny, _countof(tiny), L"long.example.com"));
+    assert_int_equal(wcscmp(tiny, L"abc"), 0);
+}
+
+static void
+test_remove_search_list_tokens(void **state)
+{
+    WCHAR list[256];
+
+    /* substring rejection: removing "vpn.corp.local" does NOT touch
+     * "test.vpn.corp.local" -- the original wcsstr-based code did. */
+    wcscpy(list, L"test.vpn.corp.local,other.com,vpn.corp.local");
+    assert_int_equal(RemoveSearchListTokens(list, L"vpn.corp.local"), 1);
+    assert_int_equal(wcscmp(list, L"test.vpn.corp.local,other.com"), 0);
+
+    /* multiset semantics: one undo token removes one occurrence */
+    wcscpy(list, L"corp.local,corp.local");
+    assert_int_equal(RemoveSearchListTokens(list, L"corp.local"), 1);
+    assert_int_equal(wcscmp(list, L"corp.local"), 0);
+
+    /* removal targets the LAST occurrence so a pre-existing prefix is
+     * preserved: with initial list "a,b" a VPN append yields "a,b,a";
+     * removing "a" must restore "a,b" (not "b,a") so the result matches
+     * the stored initial snapshot and a full reset can be triggered */
+    wcscpy(list, L"a,b,a");
+    assert_int_equal(RemoveSearchListTokens(list, L"a"), 1);
+    assert_int_equal(wcscmp(list, L"a,b"), 0);
+
+    /* multiple removes, mixed positions */
+    wcscpy(list, L"a,b,c,d");
+    assert_int_equal(RemoveSearchListTokens(list, L"b,d"), 2);
+    assert_int_equal(wcscmp(list, L"a,c"), 0);
+
+    /* no matches: list unchanged */
+    wcscpy(list, L"a,b,c");
+    assert_int_equal(RemoveSearchListTokens(list, L"x,y"), 0);
+    assert_int_equal(wcscmp(list, L"a,b,c"), 0);
+
+    /* head removal */
+    wcscpy(list, L"a,b,c");
+    assert_int_equal(RemoveSearchListTokens(list, L"a"), 1);
+    assert_int_equal(wcscmp(list, L"b,c"), 0);
+
+    /* tail removal */
+    wcscpy(list, L"a,b,c");
+    assert_int_equal(RemoveSearchListTokens(list, L"c"), 1);
+    assert_int_equal(wcscmp(list, L"a,b"), 0);
+
+    /* remove every token */
+    wcscpy(list, L"a,b");
+    assert_int_equal(RemoveSearchListTokens(list, L"a,b"), 2);
+    assert_int_equal(wcscmp(list, L""), 0);
+
+    /* duplicate undo tokens drain multiple occurrences */
+    wcscpy(list, L"x,x,x");
+    assert_int_equal(RemoveSearchListTokens(list, L"x,x"), 2);
+    assert_int_equal(wcscmp(list, L"x"), 0);
+
+    /* asking to remove more than is present only removes what's there */
+    wcscpy(list, L"x");
+    assert_int_equal(RemoveSearchListTokens(list, L"x,x,x"), 1);
+    assert_int_equal(wcscmp(list, L""), 0);
+
+    /* empty list */
+    wcscpy(list, L"");
+    assert_int_equal(RemoveSearchListTokens(list, L"a"), 0);
+    assert_int_equal(wcscmp(list, L""), 0);
+
+    /* empty remove */
+    wcscpy(list, L"a,b");
+    assert_int_equal(RemoveSearchListTokens(list, L""), 0);
+    assert_int_equal(wcscmp(list, L"a,b"), 0);
+
+    /* stray empty tokens in the list are tolerated -- the splice only
+     * touches the token being removed (and one adjacent comma) */
+    wcscpy(list, L"a,,b");
+    assert_int_equal(RemoveSearchListTokens(list, L"b"), 1);
+    assert_int_equal(wcscmp(list, L"a,"), 0);
+}
+
 int
 wmain(void)
 {
@@ -160,6 +268,8 @@ wmain(void)
     const struct CMUnitTest tests[] = {
         cmocka_unit_test(test_list_contains_domain),
         cmocka_unit_test(test_convert_itf_dns_domains),
+        cmocka_unit_test(test_append_search_list),
+        cmocka_unit_test(test_remove_search_list_tokens),
     };
 
     int ret = cmocka_run_group_tests_name("openvpnserv tests", tests, NULL, NULL);