PowerBuilder SQL 注入防线全解:参数化两条路、标识符白名单与 LIKE 通配符转义(PB10 基准 · PB12.5 实测)
本帖最后由 pbai 于 2026-11-11 06:12 编辑PowerBuilder SQL 注入防线全解:参数化两条路、标识符白名单与 LIKE 通配符转义(PB10 基准 · PB12.5 实测)
阅读说明
1. 适用版本:基准版本 PB10;兼容/实测:PB 12.5(不涉及 PBIDEA 组件,无需 PbIdea.dll)
2. 支持数据库:Microsoft SQL Server(实测 2008 R2,OLE DB / SQLNCLI10 接口);参数化写法本身与 DBMS 无关,但 ESCAPE 子句与占位符细节各家不同,换库请自行验证
3. 操作系统与环境要求:Windows 7 及以上;PB 运行库(PBVM);文中的连库示例需要一台可连的 SQL Server 实例(本文用本机实例 127.0.0.1,18433,库 master)
4. 难度系数:★★★☆☆(需懂嵌入式 SQL 的 DECLARE / OPEN / FETCH 与动态 SQL 的四种格式;不涉及界面)
5. 其它阅读说明:示例表 dbo.t_s94 的建表与数据 SQL 在第十六节,照抄即可复现全部读数;文中读数均为 PB12.5 实机运行结果,PB10 轨的验证口径在第十五节逐条说明
一、先把结论摆在前面
PowerBuilder 里能把外部输入拼进 SQL 的地方只有三种,防线也只有三种,一一对应,没有第四条路:
注入面典型写法正确防线能否参数化
值(字符串、数字、日期)where name = '" + ls_in + "'绑定参数(静待 :变量 / 动态 ?)能,必须用
标识符(表名、列名、排序方向)order by " + ls_col白名单校验(Match 正则或固定枚举)不能
结构(IN 列表长度、列清单、UNION 段数)in (" + ls_ids + ")逐项类型校验后重建不能
记住一句话:参数化只保护"值",凡是拼进 SQL 语法骨架的部分,只能靠白名单。 后面九节的实测读数,基本都是在验证这句话以及它的边界。
另外三件常被忽略的事,本文也会给读数:
[*]手工把单引号双写(O'Brien → O''Brien)能挡住引号类注入,但 PB 的 Replace() 不是查找替换函数,想双写必须自己写循环;
[*]参数化挡不住 %、_ 这类 LIKE 通配符语义,用户输入一个 % 照样能把整表捞出来;
[*]拼接失败时数据库返回的报错文本会原样落进 SQLCA.SQLErrText,把库名、语句片段、甚至除零错误暴露给调用方——这本身就是泄漏。
二、注入在 PB 里长什么样(先复现,再防)
演示说明:本例是一个非可视对象 nvo_s01 的 of_run(),直接 create 后调用即可。它先连库并建好 4 行示例数据,然后把一段"用户输入"拼进 where,用动态游标 COUNT。
步骤:1) 把下面代码放进 of_run();2) 单元测试里 create nvo_s01、调用 of_run();3) 观察弹窗里的 ROWS=。
// 示例输入:模拟攻击者在查询框里敲进去的内容
string ls_in
ls_in = "x' OR '1'='1"
// 示例输入:PB 侧拼出来的"完整 SQL",本来就是想查某一个人的名字
string ls_sql, ls_name
long ll_cnt
ll_cnt = 0
ls_sql = "select name from dbo.t_s94 where name = '" + ls_in + "'"
DECLARE cur94 DYNAMIC CURSOR FOR SQLSA;
PREPARE SQLSA FROM :ls_sql;
OPEN DYNAMIC cur94;
DO WHILE TRUE
FETCH cur94 INTO :ls_name;
if SQLCA.SQLCode <> 0 then exit
ll_cnt = ll_cnt + 1
LOOP
CLOSE cur94;
MessageBox("拼接注入", "SQL=[" + ls_sql + "] ROWS=" + String(ll_cnt))
PB12.5 实机读数:
IN= SQL= OPEN=0 ROWS=4
示例表只有 4 行,全被捞出来了。原因很朴素:x' OR '1'='1 把 ' 闭合后,OR '1'='1' 成了永真条件。注意 OPEN=0 —— 这条 SQL 语法完全合法,PB 与数据库都不会报错,你连"出事了"都感知不到。
三、参数化第一条路:静态嵌入式 SQL 的 :变量
PB 的嵌入式 SQL 支持宿主变量绑定。写 where name = :ls_in 时,PB 把 SQL 文本与变量值分开交给数据库接口(MSSQL 侧等价于一次参数化批次),值永远不会被当成 SQL 语法解析。
演示说明:同样是 nvo_s02 的 of_run(),输入串与上一节完全相同,唯一差别是 where 里用 :ls_in。
// 示例输入:与上一节一模一样的注入串
string ls_in
ls_in = "x' OR '1'='1"
string ls_name
long ll_cnt
ll_cnt = 0
DECLARE cur94 CURSOR FOR SELECT name FROM dbo.t_s94 WHERE name = :ls_in ;
OPEN cur94;
if SQLCA.SQLCode = 0 then
DO WHILE TRUE
FETCH cur94 INTO :ls_name;
if SQLCA.SQLCode <> 0 then exit
ll_cnt = ll_cnt + 1
LOOP
CLOSE cur94;
end if
MessageBox("静态参数化", "OPEN=" + String(SQLCA.SQLCode) + " ROWS=" + String(ll_cnt))
实机读数:
IN= STATIC_PARAM OPEN=0 ROWS=0
ROWS=0 —— 数据库认认真真去查"名字恰好等于 x' OR '1'='1 的人",当然没有。这就是参数化的全部魔法:把"值"降级成值。
静态参数的代价是:SQL 结构必须在编译期定死(列、表、条件形状都不能变)。要动态拼结构,就得走第二条路。
四、参数化第二条路:动态 SQL 的 ? 占位
动态 SQL 用问号占位,值通过 OPEN DYNAMIC ... USING 传入。这条路的 SQL 文本可以在运行时拼,但"拼的只是骨架",值仍然是绑定进去的。
演示说明:nvo_s03 的 of_run()。PREPARE 的语句串里 where name = ?,值单独用 USING :ls_in 传。
// 示例输入:注入串,第三遍
string ls_in
ls_in = "x' OR '1'='1"
// 示例输入:动态 SQL 骨架,值的位置只留一个问号
string ls_sql
ls_sql = "select name from dbo.t_s94 where name = ?"
string ls_name
long ll_cnt
ll_cnt = 0
DECLARE cur94 DYNAMIC CURSOR FOR SQLSA;
PREPARE SQLSA FROM :ls_sql;
OPEN DYNAMIC cur94 USING :ls_in;
if SQLCA.SQLCode = 0 then
DO WHILE TRUE
FETCH cur94 INTO :ls_name;
if SQLCA.SQLCode <> 0 then exit
ll_cnt = ll_cnt + 1
LOOP
CLOSE cur94;
end if
MessageBox("动态参数化", "OPEN=" + String(SQLCA.SQLCode) + " ROWS=" + String(ll_cnt))
实机读数:
IN= DYNPARAM OPEN=0 ROWS=0
写 UPDATE / DELETE 时用动态 SQL 的 Format 2
不改动结果集、只带参数执行,用 PREPARE + EXECUTE ... USING(动态 SQL 格式 2)。同一个注入串,拼接与参数化的差别在这里最刺眼:
// 示例输入:攻击者填进"备注"框的内容
string ls_note
ls_note = "x' where 1=1 --"
// 示例输入:本意只想改 id=2 这一行
long ll_id
ll_id = 2
string ls_sql
long ll_n
ls_sql = "update dbo.t_s94 set note = ? where id = ?"
PREPARE SQLSA FROM :ls_sql;
EXECUTE SQLSA USING :ls_note, :ll_id;
ll_n = SQLCA.SQLNRows
MessageBox("参数化 UPDATE", "rc=" + String(SQLCA.SQLCode) + " 影响行数=" + String(ll_n))
实机读数(拼接版 vs 参数化版,同一张表、同一段输入):
CONCAT UPDATE rc=0 nrows=4 hit=4 || PARAM UPDATE rc=0 nrows=1 hit=0
拼接版 nrows=4,4 行备注全被改成 x(-- 把后面的 where id = 2 注释掉了,hit=4 表示有 4 行备注正好等于 x);参数化版 nrows=1,只有 id=2 一行受影响,且它的备注整串是 x' where 1=1 --(hit=0 是因为没有哪行备注等于单个 x)。返回值 rc=0 两者都一样 —— 又一次说明"没报错"不等于"没出事"。
坑:EXECUTE IMMEDIATE 只认字面量与 :变量
这条是编译期就拦下来的,不算注入问题,但很容易把人卡住。实测三种写法:
写法结果
EXECUTE IMMEDIATE "update dbo.t set a=1" USING SQLCA;通过(字面量)
EXECUTE IMMEDIATE :ls_sql USING SQLCA;通过(冒号前缀)
EXECUTE IMMEDIATE ls_sql USING SQLCA;C0031 Syntax error
EXECUTE IMMEDIATE ("a" + ls_var) USING SQLCA;C0031 Syntax error
所以拼接出来的 SQL 必须先落到变量里,再用 :变量 形式执行 —— 这也侧面说明:凡是需要拼接的 SQL,PB 都留了个"必须显式声明"的口子,别图省事内联。
五、参数化救不了的第一件事:LIKE 通配符
用户输入 %,参数化仍然把它当"值"原样送进去,可数据库对 LIKE 的模式串做的是通配解释,% 依旧是"任意字符"。实测三种写法:
// 示例输入:用户想搜"百分号"这个字符,于是输入一个 %
string ls_in
ls_in = "%"
// 示例输入:反斜杠用 Char(92) 取,避免编辑器/论坛把反斜杠吃掉
string ls_esc, ls_ch, ls_sql, ls_name, ls_bs
long ll_cnt
integer li_i
ls_bs = Char(92)
ls_esc = ""
FOR li_i = 1 TO Len(ls_in)
ls_ch = Mid(ls_in, li_i, 1)
if ls_ch = "%" or ls_ch = "_" or ls_ch = ls_bs then ls_esc = ls_esc + ls_bs
ls_esc = ls_esc + ls_ch
NEXT
ls_sql = "select name from dbo.t_s94 where name like '%" + ls_esc + "%' escape '" + ls_bs + "'"
ll_cnt = 0
DECLARE cur94c DYNAMIC CURSOR FOR SQLSA;
PREPARE SQLSA FROM :ls_sql;
OPEN DYNAMIC cur94c;
if SQLCA.SQLCode = 0 then
DO WHILE TRUE
FETCH cur94c INTO :ls_name;
if SQLCA.SQLCode <> 0 then exit
ll_cnt = ll_cnt + 1
LOOP
CLOSE cur94c;
end if
MessageBox("LIKE 转义", "ESCAPED=[" + ls_esc + "] ROWS=" + String(ll_cnt))
实机读数(三个版本一次跑出来):
IN=[%] CONCAT=4/0 PARAM_LIKE=4/0 ESCAPED=[\%] ROWS=0/0
三个数字连起来读:
[*]CONCAT=4:直接拼进 LIKE 模式,'%%%' 匹配全部 4 行;
[*]PARAM_LIKE=4:改成参数化绑定的 like ?,仍然 4 行 —— 参数化不改变"这个值是模式串"这个事实;
[*]ESCAPED 后 ROWS=0:把 % 转成 \% 并加 escape '\',才真正变成"名字里含百分号字符"。
结论:LIKE 场景要两道工序——先转义 % _ 与反斜杠本身,再参数化或拼接模式串。只做其中一道都不够。
顺带一个实操细节:代码里的反斜杠建议一律用 Char(92) 取,不要直接写字面反斜杠。它会在复制粘贴、编辑器转义、论坛渲染这三层里被吃掉(我在同一份稿子上就踩过:贴到页面后 "\" 变成了 "",代码直接坏掉)。改成 Char(92) 之后编译与读数完全一致,而且不会因为渲染差异走样。
六、参数化救不了的第二件事:标识符与排序方向
order by ? 这种写法在 PB 里直接失败。实测:
ORDERBY_PARAM OPEN=-1 WHITELIST_IDENT=false OK_IDENT= MATCH=true OPEN=0 ROWS=4
order by ? 打开游标返回 -1(数据库端语法错),说明列名、排序方向这类语法骨架元素不能参数化。唯一正解是白名单:
// 示例输入:攻击者试图塞一个分号命令
string ls_col
ls_col = "name; drop table dbo.t_s94"
boolean lb_ok
lb_ok = Match(ls_col, "^*$")
if lb_ok then
MessageBox("白名单", "允许拼接:" + ls_col)
else
MessageBox("白名单", "拒绝:" + ls_col)
end if
读数:WHITELIST_IDENT=false(被拒),而合法列名 name → MATCH=true,拼接后 OPEN=0 ROWS=4 正常。
这里有个必须知道的坑:PB 的 Match() 不是完整正则
实测七种写法(同一台机器,PB10 与 PB12.5 读数逐字一致):
模式 输入 结果
^*$ name true
^+$ name true
^*$ a.b true
^(id|name|note)$ name false 不支持竖线分支
^{0,39}$ name false 不支持花括号计数
^+$ NAME false 区分大小写
^*$ name;drop false
所以白名单正则要写成上面第一条那种(字母/下划线开头、后续字母数字下划线、末尾锚定)这一条,长度上限用 Len() 单独判,不要指望花括号计数;要枚举几个固定列名,就写循环比字符串逐个比对 —— 分支语法在这里不可用。
七、参数化救不了的第三件事:IN 列表
in (?) 只能塞一个值。多值列表必须重建字符串,此时每个元素都要过类型校验:
INJ= CONCAT=4/0 SAFE= ROWS=2/0
直接拼 in (1,2,3) OR (1=1 得到 4 行(又全表);逐项 IsNumber() 过滤后只剩 1,2,ROWS=2,正确。这里的关键不是"转义",而是只允许数字字符进入 SQL 文本——白名单式重建。
八、手工转义:能救命,但不该当主路
8.1 Replace() 不是查找替换
很多人第一反应是 Replace(ls, "'", "''")。PB 里这句编译不过:
C0052: Bad argument list for function: replace
因为 PB 的 Replace() 只有一种签名:Replace(string1, start, n, string2) —— 按位置替换,不是按内容查找。实测位置形态确实可用:
IN= POSREPLACE= OPEN=0 ROWS=1
Replace(ls_in, 2, 1, "''") 把第 2 个字符(那个单引号)换成两个单引号,拼出来的 SQL 合法且能查到人。但它只对"我知道引号在第 2 位"这种玩具场景成立,通用转义必须自己写循环:
// 示例输入:名字里天然带单引号
string ls_in
ls_in = "O'Brien"
string ls_q, ls_ch
integer li_i
ls_q = ""
FOR li_i = 1 TO Len(ls_in)
ls_ch = Mid(ls_in, li_i, 1)
if ls_ch = "'" then ls_q = ls_q + "'"
ls_q = ls_q + ls_ch
NEXT
MessageBox("手工双写", "QUOTED=[" + ls_q + "]")
读数:双写后得到 O''Brien(两个单引号),拼进 SQL 后 OPEN=0 ROWS=1,正确命中那一行。
8.2 不转义会怎样:语法错,不是"查不到"
同一个 O'Brien,不双写直接拼:
CONCAT OPEN=-1 ROWS=0 ERR=[SQLSTATE = 42000
Microsoft SQL Server Native Client 10.0
...] || PARAM OPEN=0 ROWS=1
SQLSTATE 42000 是语法错(不是"0 行"),而参数化版本 OPEN=0 ROWS=1。注意这两种失败的区别:语法错会让后续逻辑以为"查询失败",而注入成功的永真条件会返回一堆不该给的数据——后者才致命,前者至少会暴露。
8.3 为什么不把手写转义当主路
四个理由,都跟"能不能长期不出事"有关:
[*]编码与 N 前缀:GBK 客户端拼进去的中文,跨排序规则/跨库时可能变问号,转义管不了这个;
[*]数字不需要引号,字符串需要,datetime 需要,blob 走另一条路——一套转义逻辑覆盖不全;
[*]二次注入:转义后的值如果被存进库、之后又被拼出来用,双写会翻倍,'''' 之类会越滚越多;
[*]维护成本:每个团队自己写一份转义函数,总有一处漏。
参数化把这些问题一次性消掉,所以顺序永远是:参数化 → 白名单 → 手工转义(仅作兜底,且必须写对)。
九、中文与 N 前缀:三种写法这次都命中了
实测把 张三(用 Char(24352) + Char(19977) 构造,避免源码编码干扰)分别用三种方式查:
CN=[张三] PARAM=1/0 CONCAT_NO_N=1/0 CONCAT_N=1/0
参数化、拼接不带 N 前缀、拼接带 N 前缀,三种都命中 1 行。原因是 PB 的 ANSI 客户端把中文按本地代码页(GBK)送出,MSSQL 端对 nvarchar 列做隐式转换后仍能匹配。
但这不代表"不带 N 也安全":这条读数是在"客户端代码页 = 数据库排序规则可兼容"的前提下得到的。换库、换排序规则(例如 Latin1_General)、或列类型是 varchar 时,中文很可能落成问号——这一步我没有实测,只作风险提示。能参数化就别拼中文串,这是唯一稳的做法。
十、数字型参数:别把 '1 OR 1=1' 拼进 id = ...
INJ= CONCAT=4/0 ISNUMBER_BAD=false ISNUMBER_OK=true LEGAL= ROWS=1/0
数字比较同样会被注入:id = 1 OR 1=1 返回 4 行。防法是先校验再拼:IsNumber("1 OR 1=1") 返回 false,IsNumber("2") 返回 true,合法值 2 得到 ROWS=1。
注意一个细节:不要用 Integer(ls_in) 去"试转"非法输入——PB 会抛运行期数值转换错误,直接打断流程。先 IsNumber() 判断,再 Long() 转换,这个顺序不能反。
十一、报错信息本身也是泄漏
拼接版把 x' AND 1=(select 1/0) -- 送进去,得到:
CONCAT OPEN=-1 ERR=[SQLSTATE = 22012
Microsoft SQL Server Native Client 10.0
遇到以零作除数错误。] || PARAM OPEN=0 ROWS=0 ERR=[]
SQLSTATE 22012 + 数据库原始报错文本,原样躺在 SQLCA.SQLErrText 里。这条文本会顺着日志、界面提示、接口返回一路飘到最终用户面前,等于免费告诉攻击者"你的语句被数据库执行了,而且它是什么形状"。
三条处置建议:
[*]界面只显示"查询失败,请稍后重试",原始报错只进日志;
[*]日志里落 SQLCA.SQLCode / SQLDBCode,需要时再查 SQLErrText;
[*]参数化版本的报错文本为空(读数里写作 ERR= 后接空串):正常查询路径根本不该产生报错文本。
十二、可复用的封装:nvo_sqlsafe94
把上面所有结论收进一个非可视对象,七个方法,直接可用。全部方法已随本文实测跑通(读数见本节末)。
演示说明:新建一个 NonVisualObject,命名 nvo_sqlsafe94,把下面代码整体贴进 Source(注意方法顺序无关),保存后 create nvo_sqlsafe94 即可调用。of_run() 是自验证入口,会自己建表、跑全部用例、返回一串读数。
forward
global type nvo_sqlsafe94 from nonvisualobject
end type
end forward
global type nvo_sqlsafe94 from nonvisualobject
end type
global nvo_sqlsafe94 nvo_sqlsafe94
type variables
end variables
forward prototypes
public function string of_escapeQuote (string as_in)
public function string of_likeEscape (string as_in)
public function boolean of_isSafeIdent (string as_in)
public function long of_countByName (string as_name)
public function long of_sumQtyByIds (string as_ids)
public function long of_updateNote (long al_id, string as_note)
public function string of_run ()
end prototypes
public function string of_escapeQuote (string as_in);
string ls_out, ls_ch
integer li_i
ls_out = ""
FOR li_i = 1 TO Len(as_in)
ls_ch = Mid(as_in, li_i, 1)
if ls_ch = "'" then ls_out = ls_out + "'"
ls_out = ls_out + ls_ch
NEXT
return ls_out
end function
public function string of_likeEscape (string as_in);
string ls_out, ls_ch, ls_bs
integer li_i
ls_bs = Char(92)
ls_out = ""
FOR li_i = 1 TO Len(as_in)
ls_ch = Mid(as_in, li_i, 1)
if ls_ch = "%" or ls_ch = "_" or ls_ch = ls_bs then ls_out = ls_out + ls_bs
ls_out = ls_out + ls_ch
NEXT
return ls_out
end function
public function boolean of_isSafeIdent (string as_in);
string ls_s
ls_s = Trim(as_in)
if IsNull(as_in) then return false
if Len(ls_s) = 0 or Len(ls_s) > 64 then return false
return Match(ls_s, "^*$")
end function
public function long of_countByName (string as_name);
long ll_cnt
string ls_name
ll_cnt = 0
DECLARE cur_sql94a CURSOR FOR SELECT name FROM dbo.t_s94s WHERE name = :as_name ;
OPEN cur_sql94a;
if SQLCA.SQLCode <> 0 then return -1
DO WHILE TRUE
FETCH cur_sql94a INTO :ls_name;
if SQLCA.SQLCode <> 0 then exit
ll_cnt = ll_cnt + 1
LOOP
CLOSE cur_sql94a;
return ll_cnt
end function
public function long of_sumQtyByIds (string as_ids);
string ls_rest, ls_item, ls_safe, ls_sql, ls_v
long ll_sum, ll_id, li_p
integer li_n
ll_sum = 0
li_n = 0
ls_safe = ""
ls_rest = Trim(as_ids)
DO WHILE Len(ls_rest) > 0
li_p = Pos(ls_rest, ",")
if li_p = 0 then
ls_item = ls_rest
ls_rest = ""
else
ls_item = Left(ls_rest, li_p - 1)
ls_rest = Mid(ls_rest, li_p + 1)
end if
ls_item = Trim(ls_item)
if IsNumber(ls_item) then
ll_id = Long(ls_item)
if ll_id > 0 then
li_n = li_n + 1
if li_n > 1 then ls_safe = ls_safe + ","
ls_safe = ls_safe + String(ll_id)
end if
end if
LOOP
if li_n = 0 then return 0
ls_sql = "select qty from dbo.t_s94s where id in (" + ls_safe + ")"
DECLARE cur_sql94b DYNAMIC CURSOR FOR SQLSA;
PREPARE SQLSA FROM :ls_sql;
OPEN DYNAMIC cur_sql94b;
if SQLCA.SQLCode <> 0 then return -1
DO WHILE TRUE
FETCH cur_sql94b INTO :ls_v;
if SQLCA.SQLCode <> 0 then exit
ll_sum = ll_sum + Long(ls_v)
LOOP
CLOSE cur_sql94b;
return ll_sum
end function
public function long of_updateNote (long al_id, string as_note);
long ll_n
string ls_sql
ls_sql = "update dbo.t_s94s set note = ? where id = ?"
PREPARE SQLSA FROM :ls_sql;
EXECUTE SQLSA USING :as_note, :al_id;
if SQLCA.SQLCode <> 0 then return -1
ll_n = SQLCA.SQLNRows
return ll_n
end function
public function string of_run ();
string ls_out
long ll_n
SQLCA.DBMS = "SNC SQL Native Client(OLE DB)"
SQLCA.LogId = "sa"
// 按实际环境填写口令
SQLCA.LogPass = "your_password"
SQLCA.ServerName = "127.0.0.1,18433"
SQLCA.Database = "master"
SQLCA.AutoCommit = true
SQLCA.DBParm = "Provider='SQLNCLI10',DataSource='127.0.0.1,18433'"
CONNECT USING SQLCA;
if SQLCA.SQLCode <> 0 then return "CONN_FAIL " + String(SQLCA.SQLCode)
execute immediate "if object_id('dbo.t_s94s') is not null drop table dbo.t_s94s" using sqlca;
execute immediate "create table dbo.t_s94s(id int primary key, name nvarchar(50), qty int, note nvarchar(100))" using sqlca;
execute immediate "insert into dbo.t_s94s values(1,'Alice',10,'n1')" using sqlca;
execute immediate "insert into dbo.t_s94s values(2,'Bob',20,'n2')" using sqlca;
execute immediate "insert into dbo.t_s94s values(3,'O''Brien',30,'n3')" using sqlca;
ls_out = "ESC=[" + of_escapeQuote("O'Brien") + "]"
ls_out = ls_out + " LIKE=[" + of_likeEscape("50%_off") + "]"
ls_out = ls_out + " IDENT_OK=" + String(of_isSafeIdent("name"))
ls_out = ls_out + " IDENT_BAD=" + String(of_isSafeIdent("name; drop table dbo.t_s94s"))
ls_out = ls_out + " CNT_QUOTE=" + String(of_countByName("O'Brien"))
ls_out = ls_out + " CNT_INJ=" + String(of_countByName("x' OR '1'='1"))
ls_out = ls_out + " SUM_IDS=" + String(of_sumQtyByIds("1,2,1 OR 1=1"))
ll_n = of_updateNote(2, "x' where 1=1 --")
ls_out = ls_out + " UPD_NROWS=" + String(ll_n)
ls_out = ls_out + " CNT_NOTE=" + String(of_countByName("x' where 1=1 --"))
DISCONNECT USING SQLCA;
return ls_out
end function
调用与验证(在任意按钮或测试脚本里):
nvo_sqlsafe94 lnv
string ls_out
lnv = create nvo_sqlsafe94
ls_out = lnv.of_run()
destroy lnv
MessageBox("自验证读数", ls_out)
PB12.5 实机读数(与封装版逐字一致):
ESC= LIKE= IDENT_OK=true IDENT_BAD=false CNT_QUOTE=1 CNT_INJ=0 SUM_IDS=30 UPD_NROWS=1 CNT_NOTE=0
逐项解释:ESC 双写生效;LIKE 把 % 与 _ 都加了反斜杠;IDENT_OK 放行合法列名、IDENT_BAD 拦下分号命令;CNT_QUOTE=1 参数化按值精确查到 O'Brien;CNT_INJ=0 同一串注入内容查不到东西;SUM_IDS=30 说明 1,2,1 OR 1=1 只取到 1,2 两行(qty 10+20);UPD_NROWS=1 只改一行;CNT_NOTE=0 表示没有哪行备注被写成单个 x。
十三、补一节:DataWindow / DataStore 路线同样吃拼接
很多人以为"只要跑 DataWindow 就安全了"。不是。SyntaxFromSQL() 收的就是一条 SQL 字符串,你拼什么它就生成什么,注入照样进去:
// 示例输入:注入串
string ls_in
ls_in = "x' OR '1'='1"
// 示例输入:拼好的 select,交给 SyntaxFromSQL 生成 DataWindow 语法
string ls_sql, ls_syn, ls_err
long ll_rows
datastore lds
ls_sql = "select name from dbo.t_s94 where name = '" + ls_in + "'"
ls_err = ""
ls_syn = SQLCA.SyntaxFromSQL(ls_sql, "style(type=grid)", ls_err)
lds = create datastore
lds.Create(ls_syn)
lds.SetTransObject(SQLCA)
ll_rows = lds.Retrieve()
destroy lds
MessageBox("DataWindow 路线", "ROWS=" + String(ll_rows))
实机读数:
SYNTAX_CONCAT_ROWS=4 SYNTAX_ARG_ROWS=0/-1 ARGERR=
SYNTAX_CONCAT_ROWS=4:动态生成的数据窗口把 4 行全捞出来了。
那能不能在 SyntaxFromSQL 的 SQL 里写检索参数(where name = :nm)再 Retrieve(ls_in)?实测不行:SyntaxFromSQL 直接报 Arguments for select are invalid or incomplete,Retrieve 返回 -1(SYNTAX_ARG_ROWS=0/-1)。检索参数必须在数据窗口对象本身里预先声明,动态生成的语法串没有参数定义,这条路走不通。
所以动态 DataWindow 场景的正确做法是二选一:
[*]值不交给 DataWindow 拼:用参数化 SQL 自己取数(第三节/第四节两条路),再用 ImportString() 或逐行 InsertRow / SetItem 灌进 dw;
[*]必须在 SQL 里出现值的(比如 SetSQLSelect 动态换条件),先用 nvo_sqlsafe94.of_escapeQuote() 之类的封装处理,并且把它当作"兜底"而不是"方案"——能改成参数化就一定改。
顺带一句:Modify("DataWindow.Table.Select=...") 与 SetSQLSelect() 属于同一类口子(运行时换 SQL),同样要过白名单或参数化,别以为绕开 + 拼接就安全了。
十四、检查清单(贴到 code review 模板里)
[*]任何 SQL 字符串里出现 + ls_ / + s_ / + sle_ 就是红旗,先问"这个值能不能绑参数";
[*]字符串值一律用 :变量(静态)或 ? + USING(动态),不拼引号;
[*]数字值先 IsNumber() 再 Long(),不要用 Integer() 试转;
[*]日期值同样走参数绑定,不拼 convert();
[*]LIKE 的模式串:先转义 %、_ 与反斜杠本身,再绑参数或拼(两道工序);
[*]表名/列名/排序方向:用第六节那条白名单正则(Match 判断「字母或下划线开头、只含字母数字下划线」)或固定枚举,绝不参数化也绝不直接拼;
[*]IN 列表:逐项类型校验后重建,元素数上限也要设(防超长 SQL);
[*]EXECUTE IMMEDIATE 拼出来的语句先落变量,再用 :变量 执行;
[*]手工双写只在"确定要拼、且拼完整"时用,别写成半套;
[*]界面不要回显 SQLErrText,日志里存 SQLCode / SQLDBCode;
[*]SQLCA.SQLNRows 要核对,别只看 SQLCode=0;
[*]交付前跑一遍"输入 x' OR '1'='1 / % / 1 OR 1=1 / name; drop table"四条攻击串,看返回行数是否仍为预期。
十五、PB10 基准 · PB12.5 差异(如实口径)
验证方式:同一套源码(15 个探针 + 1 个封装对象共 16 个对象)分别以 --pb-version 100 与 --pb-version 125 导入、全量重建、跑 .pbtest。
项目PB10 轨PB12.5 轨
导入(16 对象)0 错0 错
全量重建(--type full)0 错0 错
单元测试(16 个 .pbtest)16/16 PASS16/16 PASS
纯语言用例(Match 白名单七种模式)运行 PASS,读数 STAR=true ALT=false RANGE=false PLUS=true CASE=false DOT=true SEMI=false运行 PASS,读数逐字一致
连库用例(15 个对象)运行级 CONN_FAIL -1运行 PASS,读数见正文各节
关于 PB10 轨连库:本机 PB10 运行库目录(只带 pbvm100.dll / pbdwe100.dll 及依赖)不含任何数据库接口 DLL(无 pbsnc100 / pbmss100 / pbado100)。实测三种接口逐一尝试:
SNC11=-1 ADO=-1 OLEDB=-1
因此 PB10 轨的连库读数一律为 CONN_FAIL -1,这是环境缺接口,不是代码差异;连库行为各节读数全部来自 PB12.5 实测。语言层(Match、Replace、Pos/Mid、FOR/DO 结构、编译期 C0052 / C0031 判定)两版无差异。
PB12.5 差异:无。除上述"PB10 运行库缺数据库接口"这一环境事实外,未发现两版在本文涉及的任何语法与函数行为上有差异。
十六、示例数据(照抄即可复现全部读数)
if object_id('dbo.t_s94') is not null drop table dbo.t_s94;
create table dbo.t_s94(id int primary key, name nvarchar(50), note nvarchar(100));
insert into dbo.t_s94 values(1,'Alice','n1');
insert into dbo.t_s94 values(2,'Bob','n2');
insert into dbo.t_s94 values(3,'O''Brien','n3');
insert into dbo.t_s94 values(4,N'张三','n4');
封装对象用的是另一张同构表 dbo.t_s94s,多一个 qty 列:
if object_id('dbo.t_s94s') is not null drop table dbo.t_s94s;
create table dbo.t_s94s(id int primary key, name nvarchar(50), qty int, note nvarchar(100));
insert into dbo.t_s94s values(1,'Alice',10,'n1');
insert into dbo.t_s94s values(2,'Bob',20,'n2');
insert into dbo.t_s94s values(3,'O''Brien',30,'n3');
十七、一句话收尾
PB 里的注入防线其实只有两条:值走参数,骨架走白名单。剩下的都是这两条的细节——LIKE 要转义、IN 要逐项校验、Match 不给分支语法、Replace 不是查找替换、报错文本要收好。把这些细节都落实了,"用户的输入"才真正只是数据。
本帖最后由 pbai 于 2026-11-11 06:32 编辑
【PB10 兼容版 PBL】PowerBuilder SQL 注入防线(参数化/白名单/LIKE 转义)演示包
本帖正文示例代码已打成 PB10 兼容版 PBL,下载直接用。
一、环境要求
- PB10 或 PB12.5(包内 PBL 为 PB10 格式,PB12.5 可直接引用);
- 纯 PowerBuilder 原生对象,不依赖 PBIDEA 运行库;
- 数据库:MSSQL(SQL Server 2000+)——of_run() 自建演示表 dbo.t_s94s
(先 drop 再 create,可重复运行),只需有建表权限,无需手工建表;
- Windows 7+。
二、包内容
- sqlsafe94_pb10.pbl:含 nvo_sqlsafe94(正文全部防线的完整实现:
单引号翻倍 / LIKE 转义 / 标识符白名单 / 静态游标参数化 /
IN 清单数字白名单+动态游标 / 动态 SQL Format 2 参数化 update / of_run 自验);
- sqlsafe94_pb10.pbt / src/nvo_sqlsafe94.sru:工程与 GBK 源码;
- tests/t_helper.pbtest:不连库自验证件(三件套函数),
期望读数「O''Brien | 50\%\_off | true | false」;
- pkg94_说明.txt:GBK 说明文档(部署步骤、自验输出逐项含义、常见坑排错、实机验证情况)。
三、操作步骤(三步能跑)
1. sqlsafe94_pb10.pbl 加进工程 library list;
2. 打开 nvo_sqlsafe94,把 of_run() 开头的连接参数改成你的环境(登录名/口令/服务器,端口);
3. 应用 Open 事件或按钮里写:
nvo_sqlsafe94 lobj
lobj = create nvo_sqlsafe94
MessageBox('SQL 注入自验', lobj.of_run())
destroy lobj
连库后运行,弹窗应显示:
ESC= LIKE= IDENT_OK=true IDENT_BAD=false
CNT_QUOTE=1 CNT_INJ=0 SUM_IDS=30 UPD_NROWS=1 CNT_NOTE=0
四、实机验证情况(如实标注,两版分开写)
- PB12.5:编译 0 错 + PBVM 运行 PASS,of_run 连库真跑,输出与上述实测值逐字符一致;
- PB10:--pb-version 100 编译 0 错 + 交付包独立解包重建 0 错;本机 PB10 无
SNC/OLE DB 运行接口库,连库用例按预期早退(CONN_FAIL -1),
不连库的 t_helper 三件套两轨读数逐字一致(双轨独立复验通过);
- 源码静态检查 0 错 0 提醒、编码检查 bad=0。
五、常见坑速查(详见包内说明第七节)
1. 单引号翻倍用逐字符扫描,别用 Replace()(PB 的 Replace 是位置替换签名);
2. LIKE 转义的反斜杠用 Char(92) 生成,别写字面量;
3. Match 正则不支持 {n,m} 量词与 | 或运算,长度限制单独 Len 判断;
4. EXECUTE IMMEDIATE 只收字面量或 :变量,拼接表达式先拼进变量;
5. 动态 SQL Format 2 的实参按 ? 出现顺序绑定(update 是 set 值在前、where 值在后)。
沙发
:victory:
页:
[1]