大多数文章都为 php,java 等语言的代码审计,js 作为一种脚本语言,也同样存在审计的必要。
# 1. 密钥硬编码
const jwt = require('jsonwebtoken'); | |
const secret = process.env.NODE_ENV === 'production' ? process.env.JWT_SECRET : 'secret'; | |
const authService = () => { | |
... | |
}; |
第 2 行用到了 js 的三元运算符,它是一个简洁的条件判断语句
condition ? valueIfTrue : valueIfFalse |
了解了三元运算符的用法之后,就很容易看出问题在哪:
secret 作为密钥,按理说应在服务器的配置信息里获取,但如果 NODE_ENV
不是 'production'
,则会使用 'secret'
作为默认密钥。这在开发过程中可能方便,但如果不小心在生产环境中忘记设置 NODE_ENV
或 JWT_SECRET
,则会导致使用一个简单的固定值 'secret'
,密钥就被获取了。
# 2. SQL 注入
js 代码同样可能存在 sql 注入。
app.get('/api/v1/users/filter', (req, res) => { | |
var where = [] | |
var query ="SELECT * FROM users WHERE "; | |
var data = [] | |
for (var param in req.query) { | |
where.push(param+"=?") | |
data.push(req.query[param]); | |
} | |
query+=where.join(" AND "); | |
db.query(query, data, (err,rows) => { | |
if(err) return res.send(JSON.stringify({})); | |
return res.send(JSON.stringify(rows)); | |
}); | |
}); |
第 6 行:
where.push(param+"=?") |
即使使用了参数化查询,还是可以进行 sql 注入
/api/vq/user/filter?[INJECTION] = value |
此时注入的是参数名,而不是参数值。
例如:把 param
设置成 1=1 --
此时 sql 语句为:SELECT * FROM users WHERE 1=1 --
,将返回所有用户的数据
第 12 行:
return res.send(JSON.stringify(rows)); |
返回了全部数据,可能会暴露敏感信息
# 3. 只过滤一遍的正则匹配
const express = require('express') | |
const path = require('path') | |
const app = express() | |
app.get('/download', (req, res) => { | |
const filename = req.query.filename.replace(/\/\.\./g,"") | |
return res.sendFile(path.resolve(__dirname+'/'+filename)) | |
}); |
正则表达式: /\/\.\./g
,看起来很乱,逐步分析一下就好多了
/ ... /g
:这是正则表达式的语法,g
表示全局匹配,即会查找字符串中的所有符合条件的部分。\/
:匹配一个斜杠/
。在正则表达式中,斜杠/
是特殊字符,需要用反斜杠\
进行转义。\.
:匹配一个点.
。在正则表达式中,.
表示任意单个字符,因此也需要用反斜杠\
转义为字面上的点。\.\.
:表示连续的两个点..
。
所以这段表达式的意思匹配字符串中的 "/.."
模式,而 ../
通常我们用来进行目录穿越
我们还可以看到 /download,结合上面的正则,其实就是为了防止我们进行任意文件下载,但是这段代码只进行了一遍过滤,我们可以构造恶意 URL 绕过这个过滤。这种方式在其他攻击类型的绕过也很常见,如 SQL,文件上传等。
例如: ..//..
,它会进行一次过滤,变成 ../
,这样还是可以进行目录穿越的,所以
恶意 URL:test.com/download?filename=..//....//....//....//....//....//..../etc/passwd
# 4.SQL 二次注入
app.get('/api/v1/users', (req, res) => { | |
var query ="SELECT * FROM users WHERE id=?"; | |
db.query(query, req.query.id, (err,rows) => { | |
if(err) return res.send(JSON.stringify({})); | |
if(rows.length === 0) return res.send(JSON.stringify({})); | |
var q2 ="SELECT * FROM groups WHERE owner='"+rows[0].name+"'"; | |
db.query(q2, (err2, rows2) => { | |
if(err2) return res.send(JSON.stringify({})); | |
if(rows2.length === 0) return reject("") | |
return res.send(JSON.stringify(rows2)); | |
}) | |
}) | |
}); |
可以通过添加恶意用户名如 test' ,来进行 sql 注入(二次注入)
# 5.SSTI 漏洞
const https = require('https'); | |
const express = require('express') | |
const ejs = require('ejs') | |
const app = express() | |
const port = 3000 | |
app.set( "view engine", "ejs" ); | |
app.get( "/", ( req, res ) => { | |
var template = `<h1>${req.query.name}</h1>` | |
return res.send( ejs.render(template)) | |
} ); | |
app.listen(port, () => console.log(`Listening on port ${port}!`)) |
主要原因是使用了用户输入( req.query.name
)直接构造模板字符串,并传入 EJS 模板引擎渲染。导致攻击者在 name
参数中插入恶意代码,从而执行任意代码或访问服务器端的数据。
分析:
- 用户输入未经验证: 代码直接将
req.query.name
注入到template
变量中,形成<h1>${req.query.name}</h1>
,并且通过ejs.render(template)
渲染。在 EJS 模板中,<%= %>
和<%- %>
会输出变量的内容,而<%= %>
会将变量作为 HTML 文本输出,<%- %>
会将内容作为 HTML 输出(不转义 HTML 标签)。然而,由于这里是字符串插值,EJS 将会直接将${req.query.name}
解析为 JavaScript 代码。 - 未使用模板转义:
${req.query.name}
并没有使用<%= %>
这样的标签转义,从而允许直接注入的表达式执行。 - 渲染动态内容: 因为
ejs.render(template)
直接渲染用户输入的内容,这允许用户通过传入恶意的表达式来执行任意的模板指令。
例如:
http://localhost/?name=<%= process.env %>
修复也比较简单:
app.get("/", (req, res) => { | |
const name = req.query.name || "Guest"; // 默认值以防为空 | |
const template = `<h1><%= name %></h1>`; // 添加 & lt;%= %> 对 name 进行 HTML 转义,防止攻击 | |
return res.send(ejs.render(template, { name })); | |
}); |
这个请求会尝试在模板中执行 <%= process.env %>
,从而输出服务器的环境变量(如系统路径、密钥等),导致信息泄露。攻击者也可以尝试注入更复杂的表达式,例如调用系统命令或其他内部函数,具体取决于应用环境和模板引擎的特性
# 6. 日志注入
const express = require('express') | |
const app = express() | |
const port = 3000 | |
app.get('/dangerous', (req, res) => { | |
/* [...] */ | |
console.log("Access to dangerous function: "+req.ip); | |
/* [...] */ | |
}); | |
app.listen(port, () => console.log(`Example app listening on port ${port}!`)) |
日志记录时,直接将用户的 IP 地址输出到控制台,而没有进行任何验证或处理,这可能导致 日志注入 攻击。具体问题如下:
分析
-
日志注入: 如果攻击者能够控制
req.ip
(如用X-Forwarded-For
伪造),可以通过构造恶意的请求,使得日志内容被注入任意数据。攻击者可以通过在 IP 地址中添加特殊字符或控制字符,甚至包含伪造的日志内容,来干扰或伪造日志记录,导致日志误导或注入恶意代码;如果知道日志文件的位置,还可以结合日志文件 -
伪造多行日志: 攻击者可以构造恶意的 IP 地址或使用代理来伪造多行日志,插入换行符或其他控制字符,制造混乱。例如:
console.log("Access to dangerous function: " + req.ip);
如果
req.ip
被伪造为127.0.0.1\nMalicious log entry\nAnother log entry
, 这样日志会显示为:Access to dangerous function: 127.0.0.1
Malicious log entry
Another log entry
可能掩盖真正的日志信息,导致日志文件难以审计和分析。
# 后记
对于 JavaScript,还有很多利用方法,比如敏感数据泄露,数据加解密,路径泄露等,后续有动力再总结吧。