Skip to content

Commit ffed50e

Browse files
committed
pythongh-118486: Support mkdir(mode=0o700) on Windows
1 parent ba43157 commit ffed50e

File tree

6 files changed

+242
-2
lines changed

6 files changed

+242
-2
lines changed

Doc/library/os.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2178,6 +2178,10 @@ features:
21782178
platform-dependent. On some platforms, they are ignored and you should call
21792179
:func:`chmod` explicitly to set them.
21802180

2181+
On Windows, a *mode* of ``0o700`` is specifically handled to apply access
2182+
control to the new directory such that only the current user and
2183+
administrators have access. Other values of *mode* are ignored.
2184+
21812185
This function can also support :ref:`paths relative to directory descriptors
21822186
<dir_fd>`.
21832187

@@ -2192,6 +2196,9 @@ features:
21922196
.. versionchanged:: 3.6
21932197
Accepts a :term:`path-like object`.
21942198

2199+
.. versionchanged:: 3.11.10
2200+
Windows now handles a *mode* of ``0o700``.
2201+
21952202

21962203
.. function:: makedirs(name, mode=0o777, exist_ok=False)
21972204

Doc/whatsnew/3.11.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,6 +899,13 @@ os
899899
instead of ``CryptGenRandom()`` which is deprecated.
900900
(Contributed by Donghee Na in :issue:`44611`.)
901901

902+
* As of 3.11.10, :func:`os.mkdir` and :func:`os.makedirs` on Windows
903+
now support passing a *mode* value of ``0o700`` to apply access
904+
control to the new directory. This implicitly affects
905+
:func:`tempfile.mkdtemp` and is a mitigation for :cve:`2024-4030`.
906+
Other values for *mode* continue to be ignored.
907+
(Contributed by Steve Dower in :gh:`118486`.)
908+
902909

903910
.. _whatsnew311-pathlib:
904911

@@ -1059,6 +1066,11 @@ tempfile
10591066
such as compression modules.
10601067
(Contributed by Carey Metcalfe in :gh:`70363`.)
10611068

1069+
* As of 3.11.10 on Windows, the default mode ``0o700`` used by
1070+
:func:`tempfile.mkdtemp` now limits access to the new directory due to
1071+
changes to :func:`os.mkdir`. This is a mitigation for :cve:`2024-4030`.
1072+
(Contributed by Steve Dower in :gh:`118486`.)
1073+
10621074

10631075
.. _whatsnew311-threading:
10641076

Lib/test/test_os.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1743,6 +1743,25 @@ def test_exist_ok_existing_regular_file(self):
17431743
self.assertRaises(OSError, os.makedirs, path, exist_ok=True)
17441744
os.remove(path)
17451745

1746+
@unittest.skipUnless(os.name == 'nt', "requires Windows")
1747+
def test_win32_mkdir_700(self):
1748+
base = os_helper.TESTFN
1749+
path1 = os.path.join(os_helper.TESTFN, 'dir1')
1750+
path2 = os.path.join(os_helper.TESTFN, 'dir2')
1751+
# mode=0o700 is special-cased to override ACLs on Windows
1752+
# There's no way to know exactly how the ACLs will look, so we'll
1753+
# check that they are different from a regularly created directory.
1754+
os.mkdir(path1, mode=0o700)
1755+
os.mkdir(path2, mode=0o777)
1756+
1757+
out1 = subprocess.check_output(["icacls.exe", path1], encoding="oem")
1758+
out2 = subprocess.check_output(["icacls.exe", path2], encoding="oem")
1759+
os.rmdir(path1)
1760+
os.rmdir(path2)
1761+
out1 = out1.replace(path1, "<PATH>")
1762+
out2 = out2.replace(path2, "<PATH>")
1763+
self.assertNotEqual(out1, out2)
1764+
17461765
def tearDown(self):
17471766
path = os.path.join(os_helper.TESTFN, 'dir1', 'dir2', 'dir3',
17481767
'dir4', 'dir5', 'dir6')

Lib/test/test_tempfile.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import stat
1212
import types
1313
import weakref
14+
import subprocess
1415
from unittest import mock
1516

1617
import unittest
@@ -801,6 +802,33 @@ def test_mode(self):
801802
finally:
802803
os.rmdir(dir)
803804

805+
@unittest.skipUnless(os.name == "nt", "Only on Windows.")
806+
def test_mode_win32(self):
807+
# Use icacls.exe to extract the users with some level of access
808+
# Main thing we are testing is that the BUILTIN\Users group has
809+
# no access. The exact ACL is going to vary based on which user
810+
# is running the test.
811+
dir = self.do_create()
812+
try:
813+
out = subprocess.check_output(["icacls.exe", dir], encoding="oem").casefold()
814+
finally:
815+
os.rmdir(dir)
816+
817+
dir = dir.casefold()
818+
users = set()
819+
found_user = False
820+
for line in out.strip().splitlines():
821+
acl = None
822+
# First line of result includes our directory
823+
if line.startswith(dir):
824+
acl = line.removeprefix(dir).strip()
825+
elif line and line[:1].isspace():
826+
acl = line.strip()
827+
if acl:
828+
users.add(acl.partition(":")[0])
829+
830+
self.assertNotIn(r"BUILTIN\Users".casefold(), users)
831+
804832
def test_collision_with_existing_file(self):
805833
# mkdtemp tries another name when a file with
806834
# the chosen name already exists
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
:func:`os.mkdir` on Windows now accepts *mode* of ``0o700`` to restrict
2+
the new directory to the current user. This fixes :cve:`2024-4030`
3+
affecting :func:`tempfile.mkdtemp` in scenarios where the base temporary
4+
directory is more permissive than the default.

Modules/posixmodule.c

Lines changed: 172 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@
3434
#include "pycore_pystate.h" // _PyInterpreterState_GET()
3535
#include "pycore_signal.h" // Py_NSIG
3636

37+
#ifdef MS_WINDOWS
38+
# include <aclapi.h> // SetEntriesInAcl
39+
# include <sddl.h> // SDDL_REVISION_1
40+
#endif
41+
3742
#include "structmember.h" // PyMemberDef
3843
#ifndef MS_WINDOWS
3944
# include "posixmodule.h"
@@ -4582,6 +4587,146 @@ os__path_normpath_impl(PyObject *module, PyObject *path)
45824587
return result;
45834588
}
45844589

4590+
#ifdef MS_WINDOWS
4591+
4592+
/* We centralise SECURITY_ATTRIBUTE initialization based around
4593+
templates that will probably mostly match common POSIX mode settings.
4594+
The _Py_SECURITY_ATTRIBUTE_DATA structure contains temporary data, as
4595+
a constructed SECURITY_ATTRIBUTE structure typically refers to memory
4596+
that has to be alive while it's being used.
4597+
4598+
Typical use will look like:
4599+
SECURITY_ATTRIBUTES *pSecAttr = NULL;
4600+
struct _Py_SECURITY_ATTRIBUTE_DATA secAttrData;
4601+
int error, error2;
4602+
4603+
Py_BEGIN_ALLOW_THREADS
4604+
switch (mode) {
4605+
case 0x1C0: // 0o700
4606+
error = initializeMkdir700SecurityAttributes(&pSecAttr, &secAttrData);
4607+
break;
4608+
...
4609+
default:
4610+
error = initializeDefaultSecurityAttributes(&pSecAttr, &secAttrData);
4611+
break;
4612+
}
4613+
4614+
if (!error) {
4615+
// do operation, passing pSecAttr
4616+
}
4617+
4618+
// Unconditionally clear secAttrData.
4619+
error2 = clearSecurityAttributes(&pSecAttr, &secAttrData);
4620+
if (!error) {
4621+
error = error2;
4622+
}
4623+
Py_END_ALLOW_THREADS
4624+
4625+
if (error) {
4626+
PyErr_SetFromWindowsErr(error);
4627+
return NULL;
4628+
}
4629+
*/
4630+
4631+
struct _Py_SECURITY_ATTRIBUTE_DATA {
4632+
SECURITY_ATTRIBUTES securityAttributes;
4633+
PACL acl;
4634+
SECURITY_DESCRIPTOR sd;
4635+
EXPLICIT_ACCESS_W ea[4];
4636+
char sid[64];
4637+
};
4638+
4639+
static int
4640+
initializeDefaultSecurityAttributes(
4641+
PSECURITY_ATTRIBUTES *securityAttributes,
4642+
struct _Py_SECURITY_ATTRIBUTE_DATA *data
4643+
) {
4644+
assert(securityAttributes);
4645+
assert(data);
4646+
*securityAttributes = NULL;
4647+
memset(data, 0, sizeof(*data));
4648+
return 0;
4649+
}
4650+
4651+
static int
4652+
initializeMkdir700SecurityAttributes(
4653+
PSECURITY_ATTRIBUTES *securityAttributes,
4654+
struct _Py_SECURITY_ATTRIBUTE_DATA *data
4655+
) {
4656+
assert(securityAttributes);
4657+
assert(data);
4658+
*securityAttributes = NULL;
4659+
memset(data, 0, sizeof(*data));
4660+
4661+
if (!InitializeSecurityDescriptor(&data->sd, SECURITY_DESCRIPTOR_REVISION)
4662+
|| !SetSecurityDescriptorGroup(&data->sd, NULL, TRUE)) {
4663+
return GetLastError();
4664+
}
4665+
4666+
int use_alias = 0;
4667+
DWORD cbSid = sizeof(data->sid);
4668+
if (!CreateWellKnownSid(WinCreatorOwnerRightsSid, NULL, (PSID)data->sid, &cbSid)) {
4669+
use_alias = 1;
4670+
}
4671+
4672+
data->securityAttributes.nLength = sizeof(SECURITY_ATTRIBUTES);
4673+
data->ea[0].grfAccessPermissions = GENERIC_ALL;
4674+
data->ea[0].grfAccessMode = SET_ACCESS;
4675+
data->ea[0].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
4676+
if (use_alias) {
4677+
data->ea[0].Trustee.TrusteeForm = TRUSTEE_IS_NAME;
4678+
data->ea[0].Trustee.TrusteeType = TRUSTEE_IS_ALIAS;
4679+
data->ea[0].Trustee.ptstrName = L"CURRENT_USER";
4680+
} else {
4681+
data->ea[0].Trustee.TrusteeForm = TRUSTEE_IS_SID;
4682+
data->ea[0].Trustee.TrusteeType = TRUSTEE_IS_WELL_KNOWN_GROUP;
4683+
data->ea[0].Trustee.ptstrName = (LPWCH)(SID*)data->sid;
4684+
}
4685+
4686+
data->ea[1].grfAccessPermissions = GENERIC_ALL;
4687+
data->ea[1].grfAccessMode = SET_ACCESS;
4688+
data->ea[1].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
4689+
data->ea[1].Trustee.TrusteeForm = TRUSTEE_IS_NAME;
4690+
data->ea[1].Trustee.TrusteeType = TRUSTEE_IS_ALIAS;
4691+
data->ea[1].Trustee.ptstrName = L"SYSTEM";
4692+
4693+
data->ea[2].grfAccessPermissions = GENERIC_ALL;
4694+
data->ea[2].grfAccessMode = SET_ACCESS;
4695+
data->ea[2].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
4696+
data->ea[2].Trustee.TrusteeForm = TRUSTEE_IS_NAME;
4697+
data->ea[2].Trustee.TrusteeType = TRUSTEE_IS_ALIAS;
4698+
data->ea[2].Trustee.ptstrName = L"ADMINISTRATORS";
4699+
4700+
int r = SetEntriesInAclW(3, data->ea, NULL, &data->acl);
4701+
if (r) {
4702+
return r;
4703+
}
4704+
if (!SetSecurityDescriptorDacl(&data->sd, TRUE, data->acl, FALSE)) {
4705+
return GetLastError();
4706+
}
4707+
data->securityAttributes.lpSecurityDescriptor = &data->sd;
4708+
*securityAttributes = &data->securityAttributes;
4709+
return 0;
4710+
}
4711+
4712+
static int
4713+
clearSecurityAttributes(
4714+
PSECURITY_ATTRIBUTES *securityAttributes,
4715+
struct _Py_SECURITY_ATTRIBUTE_DATA *data
4716+
) {
4717+
assert(securityAttributes);
4718+
assert(data);
4719+
*securityAttributes = NULL;
4720+
if (data->acl) {
4721+
if (LocalFree((void *)data->acl)) {
4722+
return GetLastError();
4723+
}
4724+
}
4725+
return 0;
4726+
}
4727+
4728+
#endif
4729+
45854730
/*[clinic input]
45864731
os.mkdir
45874732
@@ -4611,6 +4756,12 @@ os_mkdir_impl(PyObject *module, path_t *path, int mode, int dir_fd)
46114756
/*[clinic end generated code: output=a70446903abe821f input=a61722e1576fab03]*/
46124757
{
46134758
int result;
4759+
#ifdef MS_WINDOWS
4760+
int error = 0;
4761+
int pathError = 0;
4762+
SECURITY_ATTRIBUTES *pSecAttr = NULL;
4763+
struct _Py_SECURITY_ATTRIBUTE_DATA secAttrData;
4764+
#endif
46144765
#ifdef HAVE_MKDIRAT
46154766
int mkdirat_unavailable = 0;
46164767
#endif
@@ -4622,11 +4773,30 @@ os_mkdir_impl(PyObject *module, path_t *path, int mode, int dir_fd)
46224773

46234774
#ifdef MS_WINDOWS
46244775
Py_BEGIN_ALLOW_THREADS
4625-
result = CreateDirectoryW(path->wide, NULL);
4776+
switch (mode) {
4777+
case 0x1C0: // 0o700
4778+
error = initializeMkdir700SecurityAttributes(&pSecAttr, &secAttrData);
4779+
break;
4780+
default:
4781+
error = initializeDefaultSecurityAttributes(&pSecAttr, &secAttrData);
4782+
break;
4783+
}
4784+
if (!error) {
4785+
result = CreateDirectoryW(path->wide, pSecAttr);
4786+
error = clearSecurityAttributes(&pSecAttr, &secAttrData);
4787+
} else {
4788+
// Ignore error from "clear" - we have a more interesting one already
4789+
clearSecurityAttributes(&pSecAttr, &secAttrData);
4790+
}
46264791
Py_END_ALLOW_THREADS
46274792

4628-
if (!result)
4793+
if (error) {
4794+
PyErr_SetFromWindowsErr(error);
4795+
return NULL;
4796+
}
4797+
if (!result) {
46294798
return path_error(path);
4799+
}
46304800
#else
46314801
Py_BEGIN_ALLOW_THREADS
46324802
#if HAVE_MKDIRAT

0 commit comments

Comments
 (0)