Skip to content

Commit 2438cdf

Browse files
authored
bpo-36085: Enable better DLL resolution on Windows (GH-12302)
1 parent 32119e1 commit 2438cdf

File tree

12 files changed

+492
-22
lines changed

12 files changed

+492
-22
lines changed

Doc/library/ctypes.rst

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,14 +1322,14 @@ There are several ways to load shared libraries into the Python process. One
13221322
way is to instantiate one of the following classes:
13231323

13241324

1325-
.. class:: CDLL(name, mode=DEFAULT_MODE, handle=None, use_errno=False, use_last_error=False)
1325+
.. class:: CDLL(name, mode=DEFAULT_MODE, handle=None, use_errno=False, use_last_error=False, winmode=0)
13261326

13271327
Instances of this class represent loaded shared libraries. Functions in these
13281328
libraries use the standard C calling convention, and are assumed to return
13291329
:c:type:`int`.
13301330

13311331

1332-
.. class:: OleDLL(name, mode=DEFAULT_MODE, handle=None, use_errno=False, use_last_error=False)
1332+
.. class:: OleDLL(name, mode=DEFAULT_MODE, handle=None, use_errno=False, use_last_error=False, winmode=0)
13331333

13341334
Windows only: Instances of this class represent loaded shared libraries,
13351335
functions in these libraries use the ``stdcall`` calling convention, and are
@@ -1342,7 +1342,7 @@ way is to instantiate one of the following classes:
13421342
:exc:`WindowsError` used to be raised.
13431343

13441344

1345-
.. class:: WinDLL(name, mode=DEFAULT_MODE, handle=None, use_errno=False, use_last_error=False)
1345+
.. class:: WinDLL(name, mode=DEFAULT_MODE, handle=None, use_errno=False, use_last_error=False, winmode=0)
13461346

13471347
Windows only: Instances of this class represent loaded shared libraries,
13481348
functions in these libraries use the ``stdcall`` calling convention, and are
@@ -1394,6 +1394,17 @@ the Windows error code which is managed by the :func:`GetLastError` and
13941394
:func:`ctypes.set_last_error` are used to request and change the ctypes private
13951395
copy of the windows error code.
13961396

1397+
The *winmode* parameter is used on Windows to specify how the library is loaded
1398+
(since *mode* is ignored). It takes any value that is valid for the Win32 API
1399+
``LoadLibraryEx`` flags parameter. When omitted, the default is to use the flags
1400+
that result in the most secure DLL load to avoiding issues such as DLL
1401+
hijacking. Passing the full path to the DLL is the safest way to ensure the
1402+
correct library and dependencies are loaded.
1403+
1404+
.. versionchanged:: 3.8
1405+
Added *winmode* parameter.
1406+
1407+
13971408
.. data:: RTLD_GLOBAL
13981409
:noindex:
13991410

Doc/library/os.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3079,6 +3079,36 @@ to be ignored.
30793079
:func:`signal.signal`.
30803080

30813081

3082+
.. function:: add_dll_directory(path)
3083+
3084+
Add a path to the DLL search path.
3085+
3086+
This search path is used when resolving dependencies for imported
3087+
extension modules (the module itself is resolved through sys.path),
3088+
and also by :mod:`ctypes`.
3089+
3090+
Remove the directory by calling **close()** on the returned object
3091+
or using it in a :keyword:`with` statement.
3092+
3093+
See the `Microsoft documentation
3094+
<https://msdn.microsoft.com/44228cf2-6306-466c-8f16-f513cd3ba8b5>`_
3095+
for more information about how DLLs are loaded.
3096+
3097+
.. availability:: Windows.
3098+
3099+
.. versionadded:: 3.8
3100+
Previous versions of CPython would resolve DLLs using the default
3101+
behavior for the current process. This led to inconsistencies,
3102+
such as only sometimes searching :envvar:`PATH` or the current
3103+
working directory, and OS functions such as ``AddDllDirectory``
3104+
having no effect.
3105+
3106+
In 3.8, the two primary ways DLLs are loaded now explicitly
3107+
override the process-wide behavior to ensure consistency. See the
3108+
:ref:`porting notes <bpo-36085-whatsnew>` for information on
3109+
updating libraries.
3110+
3111+
30823112
.. function:: execl(path, arg0, arg1, ...)
30833113
execle(path, arg0, arg1, ..., env)
30843114
execlp(file, arg0, arg1, ...)

Doc/whatsnew/3.8.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,16 @@ asyncio
168168
On Windows, the default event loop is now :class:`~asyncio.ProactorEventLoop`.
169169

170170

171+
ctypes
172+
------
173+
174+
On Windows, :class:`~ctypes.CDLL` and subclasses now accept a *winmode* parameter
175+
to specify flags for the underlying ``LoadLibraryEx`` call. The default flags are
176+
set to only load DLL dependencies from trusted locations, including the path
177+
where the DLL is stored (if a full or partial path is used to load the initial
178+
DLL) and paths added by :func:`~os.add_dll_directory`.
179+
180+
171181
gettext
172182
-------
173183

@@ -238,6 +248,13 @@ Added new function, :func:`math.prod`, as analogous function to :func:`sum`
238248
that returns the product of a 'start' value (default: 1) times an iterable of
239249
numbers. (Contributed by Pablo Galindo in :issue:`35606`)
240250

251+
os
252+
--
253+
254+
Added new function :func:`~os.add_dll_directory` on Windows for providing
255+
additional search paths for native dependencies when importing extension
256+
modules or loading DLLs using :mod:`ctypes`.
257+
241258

242259
os.path
243260
-------
@@ -727,6 +744,19 @@ Changes in the Python API
727744
environment variable and does not use :envvar:`HOME`, which is not normally
728745
set for regular user accounts.
729746

747+
.. _bpo-36085-whatsnew:
748+
749+
* DLL dependencies for extension modules and DLLs loaded with :mod:`ctypes` on
750+
Windows are now resolved more securely. Only the system paths, the directory
751+
containing the DLL or PYD file, and directories added with
752+
:func:`~os.add_dll_directory` are searched for load-time dependencies.
753+
Specifically, :envvar:`PATH` and the current working directory are no longer
754+
used, and modifications to these will no longer have any effect on normal DLL
755+
resolution. If your application relies on these mechanisms, you should check
756+
for :func:`~os.add_dll_directory` and if it exists, use it to add your DLLs
757+
directory while loading your library.
758+
(See :issue:`36085`.)
759+
730760

731761
Changes in the C API
732762
--------------------

Lib/ctypes/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,8 @@ class CDLL(object):
326326

327327
def __init__(self, name, mode=DEFAULT_MODE, handle=None,
328328
use_errno=False,
329-
use_last_error=False):
329+
use_last_error=False,
330+
winmode=None):
330331
self._name = name
331332
flags = self._func_flags_
332333
if use_errno:
@@ -341,6 +342,15 @@ def __init__(self, name, mode=DEFAULT_MODE, handle=None,
341342
"""
342343
if name and name.endswith(")") and ".a(" in name:
343344
mode |= ( _os.RTLD_MEMBER | _os.RTLD_NOW )
345+
if _os.name == "nt":
346+
if winmode is not None:
347+
mode = winmode
348+
else:
349+
import nt
350+
mode = nt._LOAD_LIBRARY_SEARCH_DEFAULT_DIRS
351+
if '/' in name or '\\' in name:
352+
self._name = nt._getfullpathname(self._name)
353+
mode |= nt._LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR
344354

345355
class _FuncPtr(_CFuncPtr):
346356
_flags_ = flags

Lib/ctypes/test/test_loading.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from ctypes import *
22
import os
3+
import shutil
4+
import subprocess
35
import sys
6+
import sysconfig
47
import unittest
58
import test.support
69
from ctypes.util import find_library
@@ -112,5 +115,65 @@ def test_1703286_B(self):
112115
# This is the real test: call the function via 'call_function'
113116
self.assertEqual(0, call_function(proc, (None,)))
114117

118+
@unittest.skipUnless(os.name == "nt",
119+
'test specific to Windows')
120+
def test_load_dll_with_flags(self):
121+
_sqlite3 = test.support.import_module("_sqlite3")
122+
src = _sqlite3.__file__
123+
if src.lower().endswith("_d.pyd"):
124+
ext = "_d.dll"
125+
else:
126+
ext = ".dll"
127+
128+
with test.support.temp_dir() as tmp:
129+
# We copy two files and load _sqlite3.dll (formerly .pyd),
130+
# which has a dependency on sqlite3.dll. Then we test
131+
# loading it in subprocesses to avoid it starting in memory
132+
# for each test.
133+
target = os.path.join(tmp, "_sqlite3.dll")
134+
shutil.copy(src, target)
135+
shutil.copy(os.path.join(os.path.dirname(src), "sqlite3" + ext),
136+
os.path.join(tmp, "sqlite3" + ext))
137+
138+
def should_pass(command):
139+
with self.subTest(command):
140+
subprocess.check_output(
141+
[sys.executable, "-c",
142+
"from ctypes import *; import nt;" + command],
143+
cwd=tmp
144+
)
145+
146+
def should_fail(command):
147+
with self.subTest(command):
148+
with self.assertRaises(subprocess.CalledProcessError):
149+
subprocess.check_output(
150+
[sys.executable, "-c",
151+
"from ctypes import *; import nt;" + command],
152+
cwd=tmp, stderr=subprocess.STDOUT,
153+
)
154+
155+
# Default load should not find this in CWD
156+
should_fail("WinDLL('_sqlite3.dll')")
157+
158+
# Relative path (but not just filename) should succeed
159+
should_pass("WinDLL('./_sqlite3.dll')")
160+
161+
# Insecure load flags should succeed
162+
should_pass("WinDLL('_sqlite3.dll', winmode=0)")
163+
164+
# Full path load without DLL_LOAD_DIR shouldn't find dependency
165+
should_fail("WinDLL(nt._getfullpathname('_sqlite3.dll'), " +
166+
"winmode=nt._LOAD_LIBRARY_SEARCH_SYSTEM32)")
167+
168+
# Full path load with DLL_LOAD_DIR should succeed
169+
should_pass("WinDLL(nt._getfullpathname('_sqlite3.dll'), " +
170+
"winmode=nt._LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR)")
171+
172+
# User-specified directory should succeed
173+
should_pass("import os; p = os.add_dll_directory(os.getcwd());" +
174+
"WinDLL('_sqlite3.dll'); p.close()")
175+
176+
177+
115178
if __name__ == "__main__":
116179
unittest.main()

Lib/os.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,3 +1070,40 @@ def __fspath__(self):
10701070
@classmethod
10711071
def __subclasshook__(cls, subclass):
10721072
return hasattr(subclass, '__fspath__')
1073+
1074+
1075+
if name == 'nt':
1076+
class _AddedDllDirectory:
1077+
def __init__(self, path, cookie, remove_dll_directory):
1078+
self.path = path
1079+
self._cookie = cookie
1080+
self._remove_dll_directory = remove_dll_directory
1081+
def close(self):
1082+
self._remove_dll_directory(self._cookie)
1083+
self.path = None
1084+
def __enter__(self):
1085+
return self
1086+
def __exit__(self, *args):
1087+
self.close()
1088+
def __repr__(self):
1089+
if self.path:
1090+
return "<AddedDllDirectory({!r})>".format(self.path)
1091+
return "<AddedDllDirectory()>"
1092+
1093+
def add_dll_directory(path):
1094+
"""Add a path to the DLL search path.
1095+
1096+
This search path is used when resolving dependencies for imported
1097+
extension modules (the module itself is resolved through sys.path),
1098+
and also by ctypes.
1099+
1100+
Remove the directory by calling close() on the returned object or
1101+
using it in a with statement.
1102+
"""
1103+
import nt
1104+
cookie = nt._add_dll_directory(path)
1105+
return _AddedDllDirectory(
1106+
path,
1107+
cookie,
1108+
nt._remove_dll_directory
1109+
)

Lib/test/test_import/__init__.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import platform
99
import py_compile
1010
import random
11+
import shutil
12+
import subprocess
1113
import stat
1214
import sys
1315
import threading
@@ -17,6 +19,7 @@
1719
import textwrap
1820
import errno
1921
import contextlib
22+
import glob
2023

2124
import test.support
2225
from test.support import (
@@ -460,6 +463,51 @@ def run():
460463
finally:
461464
del sys.path[0]
462465

466+
@unittest.skipUnless(sys.platform == "win32", "Windows-specific")
467+
def test_dll_dependency_import(self):
468+
from _winapi import GetModuleFileName
469+
dllname = GetModuleFileName(sys.dllhandle)
470+
pydname = importlib.util.find_spec("_sqlite3").origin
471+
depname = os.path.join(
472+
os.path.dirname(pydname),
473+
"sqlite3{}.dll".format("_d" if "_d" in pydname else ""))
474+
475+
with test.support.temp_dir() as tmp:
476+
tmp2 = os.path.join(tmp, "DLLs")
477+
os.mkdir(tmp2)
478+
479+
pyexe = os.path.join(tmp, os.path.basename(sys.executable))
480+
shutil.copy(sys.executable, pyexe)
481+
shutil.copy(dllname, tmp)
482+
for f in glob.glob(os.path.join(sys.prefix, "vcruntime*.dll")):
483+
shutil.copy(f, tmp)
484+
485+
shutil.copy(pydname, tmp2)
486+
487+
env = None
488+
env = {k.upper(): os.environ[k] for k in os.environ}
489+
env["PYTHONPATH"] = tmp2 + ";" + os.path.dirname(os.__file__)
490+
491+
# Test 1: import with added DLL directory
492+
subprocess.check_call([
493+
pyexe, "-Sc", ";".join([
494+
"import os",
495+
"p = os.add_dll_directory({!r})".format(
496+
os.path.dirname(depname)),
497+
"import _sqlite3",
498+
"p.close"
499+
])],
500+
stderr=subprocess.STDOUT,
501+
env=env,
502+
cwd=os.path.dirname(pyexe))
503+
504+
# Test 2: import with DLL adjacent to PYD
505+
shutil.copy(depname, tmp2)
506+
subprocess.check_call([pyexe, "-Sc", "import _sqlite3"],
507+
stderr=subprocess.STDOUT,
508+
env=env,
509+
cwd=os.path.dirname(pyexe))
510+
463511

464512
@skip_if_dont_write_bytecode
465513
class FilePermissionTests(unittest.TestCase):
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Enable better DLL resolution on Windows by using safe DLL search paths and
2+
adding :func:`os.add_dll_directory`.

0 commit comments

Comments
 (0)