马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?站点注册
×
本帖最后由 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=[x' OR '1'='1] SQL=[select name from dbo.t_s94 where name = 'x' OR '1'='1'] 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=[x' OR '1'='1] 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=[x' OR '1'='1] 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=[name] 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, "^[A-Za-z_][A-Za-z0-9_]*$")
- 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 读数逐字一致):
- 模式 输入 结果
- ^[A-Za-z_][A-Za-z0-9_]*$ name true
- ^[A-Za-z_][A-Za-z0-9_]+$ name true
- ^[A-Za-z_][A-Za-z0-9_.]*$ a.b true
- ^(id|name|note)$ name false 不支持竖线分支
- ^[A-Za-z_][A-Za-z0-9_]{0,39}$ name false 不支持花括号计数
- ^[a-z]+$ NAME false 区分大小写
- ^[A-Za-z_][A-Za-z0-9_]*$ name;drop false
复制代码
所以白名单正则要写成上面第一条那种(字母/下划线开头、后续字母数字下划线、末尾锚定)这一条,长度上限用 Len() 单独判,不要指望花括号计数;要枚举几个固定列名,就写循环比字符串逐个比对 —— 分支语法在这里不可用。
七、参数化救不了的第三件事:IN 列表
in (?) 只能塞一个值。多值列表必须重建字符串,此时每个元素都要过类型校验:
- INJ=[1,2,3) OR (1=1] CONCAT=4/0 SAFE=[1,2] 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=[O'Brien] POSREPLACE=[O''Brien] 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=[1 OR 1=1] CONCAT=4/0 ISNUMBER_BAD=false ISNUMBER_OK=true LEGAL=[2] 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, "^[A-Za-z_][A-Za-z0-9_]*$")
- 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=[O''Brien] LIKE=[50\%\_off] 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=[Arguments for select are invalid or inco]
复制代码
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 PASS | 16/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)。实测三种接口逐一尝试:
因此 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 不是查找替换、报错文本要收好。把这些细节都落实了,"用户的输入"才真正只是数据。
|