← 返回题目列表

Python 怎么设置文件权限?umask 为什么会让 chmod「失效」?

中等 第 25 / 27 题 更新于 2026/07/31
文件权限chmodumask安全

简化版

Unix 文件权限是三组 rwx(属主 / 属组 / 其他人),用八进制表示:0o644 就是 rw-r--r--。Python 里用 os.chmod(path, 0o600)Path(p).chmod(0o600) 设置,用 os.stat(p).st_mode & 0o777 读取,用 stat.filemode(st.st_mode) 得到 -rw-r--r-- 这种可读形式。最容易困惑的是 umask:创建文件时指定的权限不会原样生效,而是被 umask 减掉——公式是 实际权限 = 请求的 mode & ~umask。常见的 umask 是 0o022,所以 os.open(p, O_CREAT, 0o666) 实际得到 0o644(写位被抹掉了)。关键区分umask 只影响「创建时」的权限,os.chmod 是显式设置、不受 umask 影响——所以要精确控制权限(比如私钥文件必须 0o600),创建后再 chmod 一次,或者用 os.open(..., 0o600) 配合正确的 umask。目录权限的语义和文件不同x 表示「可以进入 / 可以访问里面的路径」,r 表示「可以列出文件名」,删除文件看的是所在目录的 w+x,而不是文件本身的权限——这就是为什么 /tmp 要加 sticky 位(0o1777:人人可写,但只有属主能删自己的文件。Windows 上没有这套模型chmod 只能切换只读属性,st_uid/st_gid 恒为 0。核心记忆:实际权限 = mode & ~umask;chmod 不受 umask 影响;删文件看目录权限。

详细版

权限位速查

八进制含义典型用途
0o644rw-r--r--普通文件(属主可写,别人只读)
0o600rw-------密钥、token、配置(只有属主能读写)
0o755rwxr-xr-x可执行脚本、目录
0o700rwx------私有目录
0o1777rwxrwxrwtsticky 位/tmp(人人可写,只能删自己的)
0o2775rwxrwsr-xsetgid 目录:新建文件继承属组(团队共享目录)
0o4755rwsr-xr-xsetuid:以文件属主身份执行(危险,尽量避免)
import os, stat
from pathlib import Path

p = Path("secret.key")
p.write_text("token")

# ① 读权限
st = p.stat()
print(oct(st.st_mode & 0o777))      # 0o644(默认,取决于 umask)
print(stat.filemode(st.st_mode))    # -rw-r--r--

# ② 设权限(★chmod 是显式设置,不受 umask 影响★)
p.chmod(0o600)                       # 等价 os.chmod(p, 0o600)
print(oct(p.stat().st_mode & 0o777)) # 0o600

# ③ 用 stat 常量拼(可读性更好,但八进制其实更常用)
os.chmod(p, stat.S_IRUSR | stat.S_IWUSR)     # = 0o600

# ④ ★umask 的影响:创建时权限会被"减掉"★
print(oct(os.umask(0o022)))          # 查看并设置 umask(★返回旧值★,没有只读的接口)
fd = os.open("a.txt", os.O_CREAT | os.O_WRONLY, 0o666)
os.close(fd)
print(oct(os.stat("a.txt").st_mode & 0o777))   # 0o644 ← ★不是 0o666!被 umask 抹掉了 022★

# ⑤ ★创建即安全:原子地创建一个只有属主可读写的文件★
fd = os.open("token.txt", os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
#            ★O_EXCL:已存在则失败(防止被预先创建的软链接劫持)★
with os.fdopen(fd, "w") as f:
    f.write("secret")
# 若 umask 是 0o077 以外的值,稳妥起见再 chmod 一次:
os.chmod("token.txt", 0o600)

# ⑥ open() 内置函数★不能指定权限★
open("b.txt", "w").close()           # 权限恒为 0o666 & ~umask
# → 要控制权限只能用 os.open,或创建后 chmod

# ⑦ 目录权限:删除文件看的是★目录★的 w+x
os.makedirs("d", exist_ok=True)
os.chmod("d", 0o555)                 # 目录只读(r-xr-xr-x)
# open("d/x.txt", "w")               # ✗ PermissionError(目录不可写,建不了新文件)
# 但目录里已有的文件,只要文件本身可写,仍然能改内容——只是不能删除/改名

# ⑧ 属主(需要 root 权限)
# os.chown(p, uid, gid)
# shutil.chown(p, user="app", group="app")     # ★支持用户名字符串,更方便★

# ⑨ ★别用 os.access 做安全判断★
# if os.access(p, os.W_OK): open(p, "w")   # ✗ TOCTOU + 按真实 uid 判断
# ✓ 直接 try: open(p, "w") except PermissionError:

⚠️ 三个必须记住的点:① umask 是「减法掩码」实际权限 = 请求的 mode & ~umask,它只作用于「创建的那一刻」,对之后的 os.chmod 没有任何影响。所以「我明明指定了 0o666 为什么变成 0o644」的答案永远是 umask(默认 022);而「我 chmod 0o777 为什么真的是 777」也不矛盾——chmod 是显式设置。② 内置的 open() 无法指定权限,新建文件的权限恒为 0o666 & ~umask(目录是 0o777 & ~umask);要精确控制必须用 os.open(path, flags, mode),且创建敏感文件时要配 O_EXCL(防止路径已被攻击者用符号链接占位)。③ umask 是进程级的全局状态且不是线程安全的——os.umask(new) 会返回旧值并立刻生效,多线程里临时改 umask 再改回来会互相干扰。稳妥的做法是不去动 umask,创建后显式 chmod 到目标权限(唯一的窗口风险是创建到 chmod 之间的一瞬间,用 os.open(..., 0o600) 可以消除)。

完整版教学

一、权限模型:三组 rwx 与八进制

每个文件有 9 个基本权限位,分成三组:
       属主(user)  属组(group)  其他人(other)
        r w x        r w x        r w x
        4 2 1        4 2 1        4 2 1

  0o644 = 6 4 4 = (4+2) (4) (4) = rw- r-- r--
  0o755 = 7 5 5 = (4+2+1)(4+1)(4+1) = rwx r-x r-x
  0o600 = 6 0 0 = rw- --- ---

  ★ 一定要写 0o 前缀:chmod(p, 644) 是十进制 644 = 0o1204,权限完全乱套且不报错

判断"我能不能做某事"的顺序(★不是"取最大权限"★):
  ① 我是属主吗?        → 只看 user 那三位(★即使 group 有更多权限也不看★)
  ② 我在属组里吗?      → 只看 group 三位
  ③ 都不是             → 看 other 三位
  ★ 推论:0o604(rw----r--)意味着属主可读写、★属组成员反而什么都不能做★、其他人可读
     这个"看似矛盾"的配置是合法的,且经常被用作面试题

rwx 对文件 vs 对目录,含义完全不同(★核心考点★):
  ┌─────┬──────────────────────┬────────────────────────────────────┐
  │ 位   │ 对文件                │ 对目录                              │
  ├─────┼──────────────────────┼────────────────────────────────────┤
  │ r   │ 读取内容              │ ★列出里面有哪些名字★(ls)           │
  │ w   │ 修改内容              │ ★在里面增/删/改名文件★(不是改文件内容)│
  │ x   │ 作为程序执行           │ ★进入/穿过这个目录★(cd、访问里面的路径)│
  └─────┴──────────────────────┴────────────────────────────────────┘

  由此推出几个反直觉的事实:
  ① ★删除文件看的是「文件所在目录」的 w+x,和文件自身权限无关★
     → 一个 0o444 的只读文件,只要你对目录有 w+x,照样能删掉它
  ② 目录只有 x 没有 r:能访问 dir/known_name.txt,但★不能 ls 列出目录内容★
     (常用于"知道文件名才能访问"的目录)
  ③ 目录只有 r 没有 x:能 ls 看到名字,但★无法读取任何一个文件★(进不去)
  ④ 目录必须有 x 才能"穿过"——想访问 /a/b/c.txt,对 /a 和 /a/b 都必须有 x

权限模型本身不难,但有三个点必须精确:第一,八进制前缀不能省——chmod(p, 644) 传的是十进制 644(等于 0o1204),权限完全错乱且不报错第二,权限检查是「按身份归组」而不是「取最大值」:先判断你是不是属主,是就只看 user 那三位(即使 group 位给了更多权限也不看),不是再看属组、最后看 other——所以 0o604 这种「属主可读写、属组成员什么都做不了、其他人可读」的配置是完全合法的。第三,rwx 对目录的含义和对文件完全不同:目录的 r 是「能列出名字」、w 是「能在里面增删改名」、x 是「能进入/穿过」。由此推出最反直觉也最常考的一条:删除一个文件看的是它所在目录的 w+x 权限,和文件自身权限无关——一个 0o444 的只读文件,只要你对目录可写就能删掉它。

二、umask:为什么创建出来的权限总是小一号

公式(★背下来★):
  实际权限 = 请求的 mode & ~umask

  常见 umask = 0o022(= ----w--w-,即"去掉属组和其他人的写权限")

  算例:
    open() 内置函数创建文件,请求的 mode 恒为 0o666
      0o666 = rw- rw- rw-
      umask = 0o022 = --- -w- -w-
      结果  = 0o666 & ~0o022 = 0o644 = rw- r-- r--     ★这就是默认 644 的由来★

    创建目录 os.mkdir 请求 0o777
      0o777 & ~0o022 = 0o755 = rwxr-xr-x                ★目录默认 755 的由来★

    umask = 0o077(更安全,常用于处理敏感数据的服务)
      0o666 & ~0o077 = 0o600     文件只有属主能读写
      0o777 & ~0o077 = 0o700     目录只有属主能进

  ★ 注意 umask 只能"减"权限,不能"加":
    os.open(p, O_CREAT, 0o600) 在 umask=0o022 下仍然是 0o600
    (因为 0o600 里本来就没有 group/other 的位可减)
    → 所以"指定小权限"总是安全的,"指定大权限"才会被削

umask 的作用范围(★关键区分★):
  ✓ 影响:open/os.open 创建文件、os.mkdir/makedirs 创建目录、
          tempfile 创建临时文件、socket 文件、解压时创建的文件
  ✗ ★不影响★:os.chmod(显式设置,想给什么就是什么)
                已存在文件的权限
                shutil.copy(★复制的是源文件的权限★,不走 umask)
  → "为什么 chmod 0o666 生效了但创建时不生效"的答案就在这里

Python 里的读写:
  old = os.umask(0o077)     # ★设置并返回旧值——没有"只读取"的接口★
  ...                        # 想只读取只能:old = os.umask(0o022); os.umask(old)
  os.umask(old)              # 恢复

  ★ umask 是★进程级★的全局状态:
    - 不是线程安全的(一个线程临时改了,别的线程创建文件也受影响)
    - 子进程会继承
    - 在多线程服务里临时改 umask 是典型的竞态源
  ✓ 更稳的做法:不动 umask,创建后显式 chmod;
    或用 os.open(path, O_CREAT|O_EXCL|O_WRONLY, 0o600)(指定小权限不会被削)

umask 是这道题的核心考点,公式必须背下来:实际权限 = 请求的 mode & ~umask。默认 umask 是 0o022,而内置 open() 创建文件时请求的 mode 恒为 0o666os.mkdir 请求 0o777——这就是「文件默认 644、目录默认 755」的由来。有个重要推论:umask 只能减权限、不能加,所以 os.open(p, O_CREAT, 0o600) 在任何常见 umask 下都还是 0o600(里面本来就没有 group/other 的位可减)——指定小权限总是安全的,指定大权限才会被削。作用范围要分清:umask 影响所有「创建」动作(open、mkdir、tempfile、解压),但不影响 os.chmod(显式设置)、不影响已存在的文件、也不影响 shutil.copy(它复制的是源文件权限)。最后一个工程陷阱:umask 是进程级全局状态、不是线程安全的,多线程服务里「临时改 umask 再改回来」会互相干扰——稳妥做法是不动它,改用 os.open(..., 0o600) 或创建后显式 chmod

三、Python 的权限 API 与「创建即安全」

读权限:
  st.st_mode & 0o777          → 0o644(只要权限位)
  st.st_mode & 0o7777         → 0o2755(含 setuid/setgid/sticky)
  stat.filemode(st.st_mode)   → '-rw-r--r--'(含类型字符)
  stat.S_IMODE(st.st_mode)    → ★官方写法★,等价 & 0o7777

设权限:
  os.chmod(path, 0o600)                    # 显式设置,★覆盖★原有权限
  Path(p).chmod(0o600)
  os.chmod(p, st.st_mode | stat.S_IXUSR)   # ★在原有基础上加一位★(先读再改)
  os.chmod(p, st.st_mode & ~stat.S_IWOTH)  # 去掉"其他人可写"
  os.fchmod(fd, 0o600)                     # ★对已打开的 fd 操作(无 TOCTOU)★
  os.chmod(p, 0o600, follow_symlinks=False)  # 部分平台支持 lchmod

设属主(通常需要 root):
  os.chown(p, uid, gid)              # 要数字 id,-1 表示不变
  shutil.chown(p, user="app", group="app")   # ★接受用户名/组名字符串,更好用★

★ 创建敏感文件的正确姿势(三种,安全性递增):
  ① 最差:先创建再改权限
     Path("token").write_text(secret)     # ← 这一瞬间是 0o644,★别人能读到★
     os.chmod("token", 0o600)             # 窗口虽短,但确实存在

  ② 较好:os.open 直接指定权限
     fd = os.open("token", os.O_CREAT | os.O_WRONLY, 0o600)
     with os.fdopen(fd, "w") as f: f.write(secret)
     → 文件★从诞生起★就是 0o600(前提:umask 不会把它削得更小,那反而更安全)

  ③ 最好:再加 O_EXCL 防劫持
     fd = os.open("token", os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
     → ★O_EXCL:如果路径已存在(包括是个符号链接)就直接失败★
       否则攻击者可以预先创建一个指向 /etc/passwd 的软链接,
       让你的程序把内容写进去(经典的符号链接攻击)

  ★ 更完整的模板(临时文件 + 原子改名,兼顾权限和原子性):
     import tempfile
     fd, tmp = tempfile.mkstemp(dir=os.path.dirname(target))  # ★mkstemp 默认就是 0o600★
     with os.fdopen(fd, "w") as f:
         f.write(secret)
     os.replace(tmp, target)              # 原子替换
     os.chmod(target, 0o600)

检查权限:★别用 os.access★
  ✗ os.access(p, os.W_OK)
    - 按★真实 uid★检查(setuid 程序里会得出错误结论)
    - 有 TOCTOU 窗口
    - 官方文档明确说"更推荐直接尝试打开"
  ✓ try: open(p, "w") except PermissionError: ...
  ✓ 只是想展示信息(不做决策)时读 st_mode 是可以的

Python 的权限 API 很直接,但有两个写法值得固化。「在原有权限基础上增删某一位」要先读再改:os.chmod(p, st.st_mode | stat.S_IXUSR) 加执行位、& ~stat.S_IWOTH 去掉「其他人可写」——直接写死一个八进制会把其他位一起覆盖掉。创建敏感文件有三档做法:最差的是「先创建再 chmod」(中间有一瞬间是 0o644,别人能读到);较好的是 os.open(path, O_CREAT|O_WRONLY, 0o600) 让文件从诞生起就是 0o600;最好的是再加 O_EXCL——如果路径已存在就直接失败,从而挡住「攻击者预先创建一个指向 /etc/passwd 的软链接、让你的程序把内容写进去」这种经典的符号链接攻击。想同时兼顾权限和原子性,标准模板是 tempfile.mkstemp(默认就是 0o600)+ os.replace。最后重申:os.access() 不要用于安全判断(按真实 uid 检查 + TOCTOU 窗口),要用就直接 try: open() except PermissionError

四、目录权限与三个特殊位

目录权限的实际效果(配合上一节的语义表):
  0o755 rwxr-xr-x   标准目录:属主可增删,其他人可进入和列出
  0o700 rwx------   私有目录:只有属主能进(★放密钥、配置的标准权限★)
  0o711 rwx--x--x   "知道名字才能访问":别人能进能读指定文件,★但 ls 不出来★
  0o555 r-xr-xr-x   只读目录:能进能列,但★谁也不能新建/删除文件★

三个特殊位(在 9 位权限之上,用第 4 位八进制表示):

  ★ sticky 位(0o1000)—— 只对目录有意义
    0o1777 = rwxrwxrwt  (最后那个 t 就是 sticky)
    效果:目录人人可写,但★只有文件属主(或目录属主、root)才能删除/改名★
    经典用途:/tmp、/var/tmp
    没有它的话:一个人人可写的目录里,任何人都能删掉别人的文件

  ★ setgid(0o2000)—— 对目录最有用
    0o2775 = rwxrwsr-x
    对目录:★在里面新建的文件/子目录自动继承目录的属组★(而不是创建者的默认组)
            → 团队共享目录的标准配置(大家建的文件都属于同一个组)
    对可执行文件:以文件所属★组★的身份运行

  ★ setuid(0o4000)—— 只对可执行文件有意义
    0o4755 = rwsr-xr-x
    效果:★以文件属主(通常是 root)的身份运行,而不是调用者的身份★
    经典例子:/usr/bin/passwd(普通用户改密码需要写 /etc/shadow)
    ★ 极度危险★:setuid 程序里的任何漏洞都直接变成提权漏洞
    → 现代做法用 capabilities 或 sudo 替代;解压归档时★必须清除 setuid 位★
    ★ 注意:Linux 对★脚本★(#! 开头)忽略 setuid 位(安全考虑),只对二进制生效

Python 里操作特殊位:
  os.chmod(d, 0o1777)                            # 直接给
  os.chmod(d, st.st_mode | stat.S_ISGID)         # 加 setgid
  os.chmod(f, st.st_mode & ~(stat.S_ISUID | stat.S_ISGID))   # ★清除 setuid/setgid★
  bool(st.st_mode & stat.S_ISUID)                # 检查

  ★ 一个安全细节:写入一个 setuid 文件后,内核会自动清除 setuid 位
    (防止"先让 root 建个 setuid 文件、再往里写自己的代码")

目录权限的组合能表达一些很实用的语义:0o700 是放密钥和敏感配置的标准权限(只有属主能进),0o711 实现「知道文件名才能访问、但 ls 列不出来」。三个特殊位要各记一句:sticky(0o1000)只对目录有意义——目录人人可写但只有属主能删自己的文件,这就是 /tmp0o1777 的原因;setgid(0o2000)用在目录上让新建文件自动继承目录的属组,是团队共享目录的标准配置;setuid(0o4000)让可执行文件以属主身份运行/usr/bin/passwd 的原理),但极度危险——setuid 程序里的任何漏洞都直接变成提权漏洞,所以解压不可信归档时必须清除 setuid 位(这正是 tarfilefilter="data" 会做的事之一)。两个冷知识:Linux 对脚本忽略 setuid 位(只对二进制生效),以及写入 setuid 文件后内核会自动清除该位

五、跨平台差异与容器场景

Windows 上这套模型基本不适用:
  st_mode 的权限位是"模拟"的:
    - 只有★只读属性★真正映射(chmod 去掉 w 位 → 设置只读属性)
    - r 和 x 位恒为开启(0o444 / 0o666 之类)
  os.chmod(p, 0o600) 和 0o644 在 Windows 上★效果一样★(都是可读写)
  st_uid / st_gid 恒为 0;os.chown 不存在
  真正的权限模型是 ACL(访问控制列表)→ 需要 pywin32 / win32security 操作

  ★ 跨平台代码的写法:
    if os.name == "posix":
        os.chmod(key_file, 0o600)
    # Windows 上要么忽略,要么用 ACL 限制到当前用户
  ★ 常见的连带问题:git 只记录"可执行位"(core.fileMode),
    Windows 上 clone 出来的文件权限由 umask 决定 → CI 里脚本"没有执行权限"

WSL / 挂载的差异:
  NTFS 挂载到 Linux(drvfs/ntfs-3g):权限往往是挂载参数统一指定的,chmod 无效
  FAT/exFAT:完全不支持 Unix 权限
  → 检测方法:chmod 之后再 stat 一次,看是否真的变了

容器里的权限问题(★高频踩坑★):
  ① 容器内 uid ≠ 宿主机 uid:
     容器里以 uid=1000 运行,挂载宿主机目录时宿主机文件属主可能是 uid=0
     → 表现为"容器里写不进去"或"宿主机看到的文件属主是个陌生数字"
     ✓ 用 --user $(id -u):$(id -g) 或在 Dockerfile 里统一 uid
  ② 以 root 运行容器写出的文件在宿主机上属于 root
     → CI 里 build 完的产物删不掉
  ③ Kubernetes 的 fsGroup / runAsUser / securityContext 会改变挂载卷的属组
  ④ 只读根文件系统(readOnlyRootFilesystem)下只有指定卷可写

实践清单:
  密钥/token/私钥文件      0o600(很多工具会★主动拒绝★权限过松的密钥,如 ssh)
  配置文件(含密码)        0o600 或 0o640(配合属组)
  日志文件                 0o640(属组可读,方便运维)
  可执行脚本               0o755
  共享上传目录             0o2775 + setgid(统一属组)
  临时目录                 0o1777 + sticky(或直接用 tempfile)

跨平台是这里最容易出事的部分。Windows 上这套权限模型基本不适用st_mode 的权限位是模拟的,只有「只读属性」真正映射chmod(p, 0o600)0o644 效果完全一样;st_uid/st_gid 恒为 0,os.chown 不存在——真正的权限模型是 ACL,要用 pywin32 操作。所以跨平台代码通常写成 if os.name == "posix": os.chmod(...)。一个高频连带问题是 git 只记录可执行位,Windows 上 clone 出来的文件权限由 umask 决定,导致 CI 里「脚本没有执行权限」。容器场景的坑更集中:容器内 uid 和宿主机 uid 不一致会导致「容器里写不进去」或「宿主机看到陌生的属主数字」;以 root 运行容器写出的文件在宿主机上属于 root(CI 里产物删不掉);K8s 的 fsGroup/runAsUser 还会改变挂载卷的属组。最后记住那份实践清单,尤其是密钥文件必须 0o600——ssh 等工具会主动拒绝权限过松的密钥文件。

六、常见任务与安全清单

任务 1:递归修改目录树的权限(★文件和目录要分开处理★)
  for root, dirs, files in os.walk(top):
      for d in dirs:  os.chmod(os.path.join(root, d), 0o755)    # 目录要 x
      for f in files: os.chmod(os.path.join(root, f), 0o644)    # 文件不要 x
  ✗ 千万别对所有东西统一 chmod 0o644 → ★目录失去 x 位后整棵树都进不去了★
  ✗ 也别统一 0o755 → 所有数据文件都变成"可执行",且放宽了权限

任务 2:安全地写一个密钥文件
  fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
  with os.fdopen(fd, "w") as f:
      f.write(secret)

任务 3:检查是否有"权限过松"的敏感文件(安全巡检)
  st = p.stat()
  if st.st_mode & (stat.S_IRGRP | stat.S_IROTH):
      print(f"⚠ {p} 可被其他用户读取: {stat.filemode(st.st_mode)}")

任务 4:让脚本可执行(保留其他位)
  st = os.stat(script)
  os.chmod(script, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)

任务 5:临时改 umask(★尽量避免,有并发风险★)
  import contextlib
  @contextlib.contextmanager
  def umask_ctx(new):
      old = os.umask(new)
      try:
          yield
      finally:
          os.umask(old)
  ★ 仅在单线程的启动阶段使用;多线程服务里请改用 os.open 显式指定 mode

安全清单(写涉及文件权限的代码时逐条检查):
  □ 敏感文件是否 0o600?是否★创建时就是★(而不是先建后改)?
  □ 创建敏感文件是否用了 O_EXCL(防符号链接劫持)?
  □ 是否误用 os.access 做安全判断?(应改为 EAFP)
  □ 解压/复制外来文件时是否清除了 setuid/setgid 位?
  □ 递归 chmod 时目录和文件是否分开处理?
  □ 临时文件是否用 tempfile(默认 0o600)而不是自己拼 /tmp/xxx?
  □ 日志/上传目录是否意外给了 other 写权限(0o777 是危险信号)?
  □ Windows 上的分支是否处理了(chmod 基本无效)?

最后是可以直接照抄的实践。递归改权限时文件和目录必须分开处理——统一 chmod 0o644 会让目录失去 x 位、整棵树都进不去,统一 0o755 又会把所有数据文件变成可执行且放宽权限。写密钥文件的标准姿势是 os.open(path, O_CREAT|O_EXCL|O_WRONLY, 0o600)临时改 umask 的上下文管理器可以写,但只适合单线程的启动阶段(多线程服务里会互相干扰,应改用 os.open 显式指定 mode)。收尾的安全清单里最值得反复检查的三条是:敏感文件是否「创建时就是 0o600」而不是先建后改创建时是否加了 O_EXCL 防符号链接劫持、以及处理外来文件时是否清除了 setuid/setgid 位

记忆钩子:「权限是三组 rwx(属主/属组/其他人),八进制表示,★写 chmod 时千万别忘 0o 前缀★(644 是十进制、完全错乱还不报错)。判断能不能做某事是『按身份归组』不是『取最大权限』——你是属主就只看 user 那三位,所以 0o604 意味着属组成员反而什么都做不了。★rwx 对目录的含义完全不同:r=能列出名字、w=能在里面增删改名、x=能进入/穿过★,由此得出最反直觉的一条:★删除文件看的是所在目录的 w+x,和文件自身权限无关★(0o444 的只读文件照样能被删),这也是 /tmp 必须加 sticky 位(0o1777)的原因——人人可写但只能删自己的。★umask 是减法掩码:实际权限 = 请求的 mode & ~umask★,默认 0o022 而 open() 请求 0o666、mkdir 请求 0o777,这就是『文件默认 644、目录默认 755』的由来;它★只影响创建那一刻★,对 os.chmod 毫无影响(这解释了『为什么 chmod 生效但创建时不生效』);而且它只能减不能加,所以指定小权限(0o600)永远安全。内置 open() ★无法指定权限★,要控制必须用 os.open(path, O_CREAT|O_EXCL|O_WRONLY, 0o600)——★O_EXCL 是为了防符号链接劫持★(攻击者预先建个指向 /etc/passwd 的软链接),而『先创建再 chmod』中间那一瞬间文件是 0o644、别人读得到。umask 是进程级全局状态、不线程安全,别在多线程里临时改。三个特殊位:sticky 只对目录(/tmp)、setgid 用在目录上让新文件继承属组(团队共享目录)、★setuid 极危险★(解压外来归档必须清除它)。os.access 按真实 uid 检查且有 TOCTOU 窗口,★别用它做安全判断★,直接 try/except。Windows 上整套模型基本无效(chmod 只切换只读属性、uid/gid 恒为 0)。」

七、常见误区与追问

  • 误区:os.chmod(p, 644) 就是设置成 rw-r--r-- 少了 0o 前缀就是十进制 644,转成八进制是 0o1204——不但权限位完全错乱(0o204-w----r--),还多带了一个 0o1000sticky 位,而且 Python 不会报任何错。这是新手最容易犯又最难自查的错误(权限看起来「怪怪的」但程序照常运行)。养成两个习惯可以避免:永远写 0o 前缀;或者用 stat 模块的常量拼(stat.S_IRUSR | stat.S_IWUSR),虽然啰嗦但不会写错。检查时也别忘了用 stat.filemode(st.st_mode) 打印出 -rw-r--r-- 这种人类可读形式来确认。
  • 误区:open(path, "w") 创建文件时可以指定权限。 内置 open() 没有权限参数——它创建文件时使用固定的 0o666,再被 umask 削减,所以默认得到 0o644。想精确控制只有两条路:① os.open(path, os.O_CREAT | os.O_WRONLY, 0o600) 然后用 os.fdopen(fd) 包装成文件对象(推荐,文件从诞生起就是目标权限);② 先用 open() 创建、再 os.chmod() 改——但这中间存在一个窗口期,文件是 0o644,其他用户可以读到内容,写密钥、token 时这个窗口就是漏洞。同理,Path.write_text()json.dump(open(...)) 这些便捷写法都无法指定权限。需要同时保证权限和原子性时,标准模板是 tempfile.mkstemp()默认就创建为 0o600)+ os.replace()
  • 误区:umask 会影响所有的权限设置,包括 chmod。 umask 只作用于「创建的那一刻」open/os.open 创建文件、mkdir 创建目录、tempfile 创建临时文件、解压时创建文件——这些场景下 实际权限 = 请求的 mode & ~umask。而 os.chmod 是显式设置,完全不受 umask 影响,你给 0o666 就是 0o666。搞清这一点就能解释那个经典困惑:「我 os.open(p, O_CREAT, 0o666) 得到的是 644,但 chmod(p, 0o666) 却真的变成了 666」。另外 shutil.copy 也不走 umask——它复制的是源文件的权限,所以从一个 0o777 的文件复制过来的新文件也是 0o777,这在拷贝外来文件时是个隐患。
  • 误区:一个文件设成 0o444(只读)就没人能删掉它了。 删除文件检查的是「文件所在目录」的 wx 权限,与文件自身权限无关——因为「删除」本质是从目录里移除一个目录项(改的是目录的内容),而不是修改文件本身。所以只要你对目录有写权限,0o444 甚至 0o000 的文件都能被删掉(rm 会提示确认,但 os.remove() 直接就删了)。要真正保护文件不被删除有三条路:① 收紧目录的权限;② 给目录加 sticky 位0o1777),这样只有文件属主能删自己的文件(/tmp 就是这么做的);③ Linux 上用 chattr +i 设置不可变属性(连 root 都要先去掉这个属性才能删)。反过来的推论同样重要:在一个人人可写的目录里,你的文件是不安全的
  • 误区:用 os.access(p, os.W_OK) 检查一下再写文件更稳妥。 两个硬伤。① 它按「真实 uid」而不是「有效 uid」检查权限——这个设计是为了 setuid 程序判断「调用我的那个用户有没有权限」,但在普通程序里会给出与实际操作不一致的结果。② TOCTOU 竞态:检查通过到真正打开之间,文件可能被删除、替换或换成指向别处的符号链接,你的检查等于白做,甚至给了攻击者可乘之机。Python 官方文档明确建议改用「直接尝试打开并捕获异常」(EAFP):try: open(p, "w") except PermissionError:——因为打开这个动作本身是原子的。os.access 唯一合理的用途是展示信息(比如给用户列出「哪些文件你能读」),而不是作为操作前的安全判断。
  • 追问:umask 的值应该怎么设?为什么默认是 022 而不是更安全的 077? umask 是一个「要减掉哪些权限」的掩码。常见值:0o022(默认)减掉属组和其他人的写权限,结果是文件 644、目录 755——这个默认值反映了传统 Unix「多用户共享一台机器、文件默认可以互相看」的假设,便于协作。0o077 减掉属组和其他人的一切权限,结果是文件 600、目录 700,适合处理敏感数据的服务(很多安全基线要求守护进程用这个值)。0o002 只减掉其他人的写权限,配合 setgid 目录用于团队共享。设置方式:shell 里 umask 077、systemd 服务里 UMask=0077、Python 里 os.umask(0o077)注意它返回旧值、没有只读接口)。但在 Python 程序里更推荐的做法是不改全局 umask(它是进程级状态、不线程安全、还会被子进程继承),而是对确实需要严格权限的文件用 os.open(..., 0o600) 显式创建——因为 umask 只能减不能加,指定小权限永远不会被削
  • 追问:setuid、setgid、sticky 各是干什么的,为什么解压归档时要清除 setuid? setuid(0o4000 让可执行文件以文件属主的身份运行而不是调用者身份——经典例子是 /usr/bin/passwd(普通用户要改密码就必须写 root 才能写的 /etc/shadow)。setgid(0o2000 对可执行文件是以属组身份运行,对目录则是「在里面新建的文件自动继承目录的属组」,这是团队共享目录的标准配置(0o2775)。sticky(0o1000 只对目录有意义:目录人人可写,但只有文件属主(或目录属主、root)能删除和改名/tmp0o1777 就是它。解压时必须清除 setuid 的原因很直接:归档里可以包含一个属主为 root、带 setuid 位的可执行文件,一旦以 root 解压出来,任何用户执行它都能获得 root 权限——这是完整的本地提权链。所以 tarfilefilter="data" 会清除 setuid/setgid 位,自己写解压逻辑时也要 mode & ~0o7000。两个补充:Linux 对脚本(#! 开头)忽略 setuid 位(因为解释器启动过程有竞态风险),以及写入一个 setuid 文件后内核会自动清除该位
  • 追问:Windows 上 os.chmod 到底做了什么?跨平台代码怎么写? Windows 没有 Unix 的 rwx 模型,它用的是 ACL(访问控制列表),粒度和语义都完全不同。Python 在 Windows 上把 os.chmod 映射到「只读属性」这一个开关:如果 mode 里属主的写位stat.S_IWUSR)为 0 就设置只读属性,否则取消——也就是说 chmod(p, 0o600)0o6440o777 在 Windows 上效果完全相同(都是可读写),0o4440o000 也相同(都是只读)。st_uid/st_gid 恒为 0,os.chown 根本不存在。跨平台代码的三种处理方式:① 平台分支——if os.name == "posix": os.chmod(f, 0o600),Windows 上要么接受默认、要么用 pywin32win32security)设置 ACL 限制到当前用户;② 依赖目录权限——把敏感文件放进只有当前用户能访问的目录(如 %LOCALAPPDATA%);③ 加密而不是靠权限。另外要留意 git 只记录可执行位core.fileMode),Windows 上 clone 出来的文件权限由 umask 决定,CI 里常见「脚本没有执行权限」的问题就来自这里。

八、加强记忆

Unix 权限是三组 rwx(属主/属组/其他人)的八进制表示——os.chmod(p, 0o600) 设置、st.st_mode & 0o777 读取、stat.filemode() 转成 -rw-r--r--写的时候千万别忘 0o 前缀644 是十进制、权限完全错乱还不报错)。判断「能不能做某事」是按身份归组而不是取最大权限:你是属主就只看 user 三位,所以 0o604 意味着属组成员反而什么都做不了。rwx 对目录的含义完全不同r 是能列出名字、w 是能在里面增删改名、x 是能进入/穿过——由此得出最反直觉的一条:删除文件看的是所在目录的 w+x,和文件自身权限无关0o444 的只读文件照样能被删),这也是 /tmp 必须加 sticky 位(0o1777 的原因:人人可写但只能删自己的。umask 是减法掩码:实际权限 = 请求的 mode & ~umask,默认 0o022open() 请求 0o666mkdir 请求 0o777,这就是「文件默认 644、目录默认 755」的由来;它只影响创建那一刻、对 os.chmod 毫无影响(这解释了「为什么 chmod 生效但创建时不生效」),而且只能减不能加,所以指定小权限(0o600)永远安全。内置 open() 无法指定权限,要精确控制必须用 os.open(path, O_CREAT|O_EXCL|O_WRONLY, 0o600)——O_EXCL 是为了防符号链接劫持(攻击者预先创建指向 /etc/passwd 的软链接),而「先创建再 chmod」中间那一瞬间文件是 0o644、别人读得到;兼顾原子性用 tempfile.mkstemp()默认 0o600)+ os.replace()。umask 是进程级全局状态、不线程安全,别在多线程里临时改。三个特殊位:sticky 只对目录有意义、setgid 用在目录上让新文件继承属组(团队共享目录 0o2775)、setuid 极危险(以属主身份运行,解压外来归档必须清除它,否则是完整的提权链)。os.access 按真实 uid 检查且有 TOCTOU 窗口,不要用它做安全判断,直接 try/except。最后,Windows 上整套模型基本无效chmod 只切换只读属性、uid/gid 恒为 0、真正的模型是 ACL),跨平台代码要做平台分支;递归改权限时目录和文件必须分开处理(统一 0o644 会让目录失去 x 位、整棵树都进不去)。