ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Oracle自定义加密函数实战:绕过DBMS_CRYPTO实现等保合规

Oracle自定义加密函数实战:绕过DBMS_CRYPTO实现等保合规 简介这份资源面向 Oracle 数据库开发与运维人员提供一套自定义加密解密函数用于解决敏感数据脱敏、加密存储与合规传输问题。包内共 3 个文件以 2 个 sql 脚本和 1 个 txt 说明为主压缩包约 5KB其中 sql 文件分别实现加密与解密逻辑txt 文档给出使用说明与注释便于快速集成到现有库中。核心函数 ENCRYPT_DES 与 DECRYPT_DES 基于 DES 标准参数可配置支持按需调整密钥与数据长度兼顾加密强度与灵活性适用于金融账户信息、医疗患者隐私、电商身份与权限数据等场景。代码附带详尽注释降低理解与维护成本经过测试优化后稳定性有保障。目前已有 521 人学习下载适合需要落地数据安全合规方案的中高级开发者参考复用。1. 为什么我宁愿手写 Oracle 加密函数也不直接调 DBMS_CRYPTO去年做等保整改有个老库要过合规检查身份证和手机号全是明文躺在 VARCHAR2 字段里。第一反应是用 DBMS_CRYPTO结果发现这玩意儿在 10g 上根本没有11g 还得额外授权生产库 DBA 死活不给 EXECUTE 权限。折腾两天后我换了个思路用 Oracle 自带的 UTL_ENCODE 和 DBMS_OBFUSCATION_TOOLKIT 手搓一套自定义加密解密函数不依赖任何外部包普通开发账号就能跑。这套方案的核心就三件事加密存储、数据脱敏、合规可审计。适合两类人——一是像我这样在老旧 Oracle 环境里做等保整改、拿不到高级权限的二是需要在 SQL 层直接对敏感字段做加解密、不想把逻辑放到应用层的。下面把我踩过的坑和能直接抄的代码全倒出来。2. 自定义加密函数怎么选型从 DBMS_OBFUSCATION_TOOLKIT 到 UTL_ENCODE2.1 为什么不用 DBMS_CRYPTO先说清楚选型逻辑。DBMS_CRYPTO 是 Oracle 10g R2 之后才有的包支持 AES、DES、3DES 等标准算法功能确实强。但实际落地时有三个硬伤第一权限问题。DBMS_CRYPTO 默认只给 SYS 执行权限普通用户要调用必须由 DBA 显式授权。在很多企业里DBA 和应用开发是两个部门走一次授权流程少则三天多则一周。等保整改往往有 deadline等不起。第二版本兼容。我手上有个 10.2.0.4 的老库DBMS_CRYPTO 压根不存在。升级数据库业务方直接说不可能。这种情况下只能用 DBMS_OBFUSCATION_TOOLKIT这个包从 8i 就有了兼容性拉满。第三审计要求。等保 2.0 里对加密算法有明确要求但没规定必须用某个特定包。只要加密逻辑可审计、密钥管理有流程、解密有权限控制自定义函数完全能过。我后来把加密函数源码打印出来给测评机构看对方确认逻辑没问题就过了。所以选型结论很明确能用 DBMS_CRYPTO 就用用不了就上 DBMS_OBFUSCATION_TOOLKIT UTL_ENCODE 组合。下面重点讲后者。2.2 核心函数拆解DES3 加密 Base64 编码DBMS_OBFUSCATION_TOOLKIT 提供的 DES3_ENCRYPT 函数输入是 RAW 类型输出也是 RAW。但我们的字段是 VARCHAR2直接存 RAW 会乱码。所以中间要加一层 UTL_ENCODE.BASE64_ENCODE 做编码转换。整个链路是这样的明文 VARCHAR2 → UTL_I18N.STRING_TO_RAW 转 RAW → DES3_ENCRYPT 加密 → UTL_ENCODE.BASE64_ENCODE 编码 → 密文 VARCHAR2 存库解密反过来密文 VARCHAR2 → UTL_ENCODE.BASE64_DECODE 解码 → DES3_DECRYPT 解密 → UTL_I18N.RAW_TO_CHAR 转字符串 → 明文 VARCHAR2这里有个关键点DES3_ENCRYPT 的密钥必须是 16 或 24 字节。我一般用 24 字节安全性更高。密钥不能硬编码在函数里常见做法是存到一张单独的密钥表加访问控制或者通过 SYS_CONTEXT 从应用传入。2.3 建包建函数完整可执行脚本先建一个加密包把加解密和密钥管理都封进去-- 创建加密包规范 CREATE OR REPLACE PACKAGE pkg_crypto AS -- 加密函数输入明文返回Base64编码的密文 FUNCTION encrypt_data(p_plain_text IN VARCHAR2) RETURN VARCHAR2; -- 解密函数输入Base64密文返回明文 FUNCTION decrypt_data(p_cipher_text IN VARCHAR2) RETURN VARCHAR2; -- 脱敏函数保留前n位和后m位中间用*代替 FUNCTION mask_data(p_input IN VARCHAR2, p_prefix IN NUMBER, p_suffix IN NUMBER) RETURN VARCHAR2; END pkg_crypto; / -- 创建加密包体 CREATE OR REPLACE PACKAGE BODY pkg_crypto AS -- 密钥常量实际项目中应从密钥表读取 c_key CONSTANT VARCHAR2(24) : MySecretKey2024!#$%^; FUNCTION encrypt_data(p_plain_text IN VARCHAR2) RETURN VARCHAR2 IS v_raw RAW(2000); v_encrypted RAW(2000); v_result VARCHAR2(4000); BEGIN -- 空值直接返回 IF p_plain_text IS NULL THEN RETURN NULL; END IF; -- 字符串转RAW v_raw : UTL_I18N.STRING_TO_RAW(p_plain_text, AL32UTF8); -- DES3加密 DBMS_OBFUSCATION_TOOLKIT.DES3_ENCRYPT( input_string v_raw, key_string UTL_I18N.STRING_TO_RAW(c_key, AL32UTF8), encrypted_string v_encrypted ); -- Base64编码 v_result : UTL_ENCODE.BASE64_ENCODE(v_encrypted); RETURN v_result; EXCEPTION WHEN OTHERS THEN -- 记录日志后抛出 RAISE_APPLICATION_ERROR(-20001, 加密失败: || SQLERRM); END encrypt_data; FUNCTION decrypt_data(p_cipher_text IN VARCHAR2) RETURN VARCHAR2 IS v_decoded RAW(2000); v_decrypted RAW(2000); v_result VARCHAR2(4000); BEGIN IF p_cipher_text IS NULL THEN RETURN NULL; END IF; -- Base64解码 v_decoded : UTL_ENCODE.BASE64_DECODE(UTL_RAW.CAST_TO_RAW(p_cipher_text)); -- DES3解密 DBMS_OBFUSCATION_TOOLKIT.DES3_DECRYPT( input_string v_decoded, key_string UTL_I18N.STRING_TO_RAW(c_key, AL32UTF8), decrypted_string v_decrypted ); -- RAW转字符串 v_result : UTL_I18N.RAW_TO_CHAR(v_decrypted, AL32UTF8); RETURN v_result; EXCEPTION WHEN OTHERS THEN RAISE_APPLICATION_ERROR(-20002, 解密失败: || SQLERRM); END decrypt_data; FUNCTION mask_data(p_input IN VARCHAR2, p_prefix IN NUMBER, p_suffix IN NUMBER) RETURN VARCHAR2 IS v_len NUMBER; v_mask VARCHAR2(100); v_result VARCHAR2(4000); BEGIN IF p_input IS NULL THEN RETURN NULL; END IF; v_len : LENGTH(p_input); -- 长度不够直接全脱敏 IF v_len p_prefix p_suffix THEN RETURN RPAD(*, v_len, *); END IF; -- 构造中间掩码 v_mask : RPAD(*, v_len - p_prefix - p_suffix, *); v_result : SUBSTR(p_input, 1, p_prefix) || v_mask || SUBSTR(p_input, -p_suffix); RETURN v_result; END mask_data; END pkg_crypto; /这段代码有三个关键设计点。第一密钥用 CONSTANT 定义在包体里实际项目应该改成从独立密钥表查询并且给密钥表加单独的访问控制。第二异常处理里用 RAISE_APPLICATION_ERROR 抛出自定义错误码方便应用层区分是加密失败还是解密失败。第三脱敏函数支持动态指定前后保留位数手机号可以保留前3后4身份证保留前6后4。2.4 测试验证加密解密跑一遍建完包先别急着改生产数据拿测试表跑一遍-- 建测试表 CREATE TABLE t_user_sensitive ( id NUMBER PRIMARY KEY, user_name VARCHAR2(50), id_card_enc VARCHAR2(200), -- 加密后的身份证 phone_enc VARCHAR2(200), -- 加密后的手机号 id_card_mask VARCHAR2(50), -- 脱敏后的身份证 phone_mask VARCHAR2(50) -- 脱敏后的手机号 ); -- 插入测试数据 INSERT INTO t_user_sensitive (id, user_name, id_card_enc, phone_enc, id_card_mask, phone_mask) VALUES ( 1, 张三, pkg_crypto.encrypt_data(110101199001011234), pkg_crypto.encrypt_data(13800138000), pkg_crypto.mask_data(110101199001011234, 6, 4), pkg_crypto.mask_data(13800138000, 3, 4) ); COMMIT; -- 验证查加密数据 SELECT id, user_name, id_card_enc, phone_enc FROM t_user_sensitive WHERE id 1; -- 验证解密还原 SELECT id, user_name, pkg_crypto.decrypt_data(id_card_enc) AS id_card_plain, pkg_crypto.decrypt_data(phone_enc) AS phone_plain, id_card_mask, phone_mask FROM t_user_sensitive WHERE id 1;跑完应该看到id_card_enc 是一串 Base64 乱码id_card_plain 还原成 110101199001011234id_card_mask 显示 110101********1234。如果解密出来是乱码八成是字符集问题检查 STRING_TO_RAW 和 RAW_TO_CHAR 的字符集参数是否一致。3. 存量数据怎么平滑迁移分批加密 双写过渡3.1 迁移策略先加列再回填生产库不可能停服让你慢慢加密。我一般分四步走第一步给敏感字段加加密列。比如原表有 id_card 字段新增 id_card_enc 字段。第二步写一个存储过程分批回填。每次处理 5000 行避免大事务把 undo 表空间撑爆。第三步应用层双写。新数据同时写明文列和加密列读的时候优先读加密列。第四步验证无误后把明文列清空或改名为备份列。回填存储过程大概长这样CREATE OR REPLACE PROCEDURE sp_migrate_encrypt( p_batch_size IN NUMBER DEFAULT 5000 ) IS v_total NUMBER; v_done NUMBER : 0; v_start NUMBER : 0; BEGIN SELECT COUNT(*) INTO v_total FROM t_user_sensitive WHERE id_card_enc IS NULL; WHILE v_done v_total LOOP -- 分批更新 UPDATE t_user_sensitive SET id_card_enc pkg_crypto.encrypt_data(id_card), phone_enc pkg_crypto.encrypt_data(phone) WHERE id IN ( SELECT id FROM t_user_sensitive WHERE id_card_enc IS NULL AND ROWNUM p_batch_size ); v_done : v_done SQL%ROWCOUNT; COMMIT; -- 记录进度 DBMS_OUTPUT.PUT_LINE(已处理: || v_done || / || v_total); -- 避免锁等待 DBMS_LOCK.SLEEP(0.5); END LOOP; DBMS_OUTPUT.PUT_LINE(迁移完成共处理 || v_done || 条); END sp_migrate_encrypt; /这里有几个参数要调。p_batch_size 默认 5000如果单行数据大或者 undo 表空间小降到 1000。DBMS_LOCK.SLEEP(0.5) 是给主库留喘息时间生产环境建议 1 秒以上。ROWNUM 条件必须放在子查询里直接写在 UPDATE 的 WHERE 里会导致全表扫描。3.2 双写过渡期的查询兼容双写期间应用层查询要兼容新旧两种数据。常见做法是建一个视图把加密列解密后和明文列做 COALESCECREATE OR REPLACE VIEW v_user_sensitive AS SELECT id, user_name, COALESCE(pkg_crypto.decrypt_data(id_card_enc), id_card) AS id_card, COALESCE(pkg_crypto.decrypt_data(phone_enc), phone) AS phone, id_card_mask, phone_mask FROM t_user_sensitive;这样应用层不用改代码直接查视图就行。等所有数据都迁移完再把视图改成只读加密列。3.3 性能影响实测加密解密是有 CPU 开销的。我在测试库上跑过对比10 万行数据全表扫描解密比直接读明文慢 3 到 5 倍。如果查询条件里用到加密字段比如 WHERE id_card_enc pkg_crypto.encrypt_data(110101...)那索引完全用不上只能全表扫。所以有个原则加密字段只用于存储和展示不要用于查询条件。需要按身份证查人就额外存一个哈希列用 SHA256 做索引。这个哈希列不可逆但能精确匹配。4. 避坑指南密钥管理、字符集和权限的五个血泪教训4.1 密钥硬编码在包体里源码一泄露全完蛋现象开发图省事把密钥写成 CONSTANT 放在包体里。结果代码仓库权限没管好外包人员拿到了源码所有加密数据等于裸奔。原因Oracle 的包体源码可以通过 USER_SOURCE 视图查到只要有权限就能看。解决密钥必须外置。建一张密钥表加独立表空间和访问控制包体里通过函数动态获取。更严格的做法是用 Oracle Wallet但配置复杂一般项目用密钥表就够了。-- 密钥表 CREATE TABLE t_crypto_key ( key_id NUMBER PRIMARY KEY, key_value VARCHAR2(100), create_time DATE DEFAULT SYSDATE, is_active NUMBER(1) DEFAULT 1 ); -- 插入密钥 INSERT INTO t_crypto_key VALUES (1, MySecretKey2024!#$%^, SYSDATE, 1); -- 包体中改为查询获取 FUNCTION get_key RETURN VARCHAR2 IS v_key VARCHAR2(100); BEGIN SELECT key_value INTO v_key FROM t_crypto_key WHERE key_id 1 AND is_active 1; RETURN v_key; END;4.2 字符集不一致导致解密乱码现象加密时用 AL32UTF8解密时用 ZHS16GBK出来的明文是问号或者乱码。原因STRING_TO_RAW 和 RAW_TO_CHAR 的字符集参数必须严格一致否则字节流对不上。解决统一用 AL32UTF8这是 Oracle 推荐的字符集。如果数据库本身是 ZHS16GBK那加密解密都用 ZHS16GBK别混着来。可以在包体里定义一个常量字符集所有函数引用同一个常量。4.3 加密后字段长度不够数据被截断现象加密前手机号 11 位加密后 Base64 字符串变成 40 多位原字段 VARCHAR2(20) 直接报错或者截断。原因DES3 加密后数据膨胀Base64 编码又增加约 33% 长度。解决加密列的长度至少是明文的 4 倍。手机号 11 位加密列给 VARCHAR2(100)。身份证 18 位给 VARCHAR2(200)。建表时宁大勿小VARCHAR2(4000) 也不占实际存储空间。4.4 普通用户没有 DBMS_OBFUSCATION_TOOLKIT 权限现象调用加密函数报 ORA-06550 或 PLS-00201提示标识符必须声明。原因DBMS_OBFUSCATION_TOOLKIT 默认只给 SYS 执行权限。解决让 DBA 授权或者用 SYS 建一个公共同义词。如果 DBA 不配合还有个偏方用 UTL_ENCODE 加自定义异或算法纯 SQL 实现不需要任何特殊包。但安全性差很多只适合对加密强度要求不高的场景。-- 授权语句需要DBA执行 GRANT EXECUTE ON DBMS_OBFUSCATION_TOOLKIT TO your_user; GRANT EXECUTE ON UTL_ENCODE TO your_user; GRANT EXECUTE ON UTL_I18N TO your_user;4.5 批量解密时 PGA 内存溢出现象一次性解密几十万行数据报 ORA-04030 out of process memory。原因每次解密都在 PGA 里分配 RAW 变量批量操作时累积占用过大。解决分批处理每批 1000 到 5000 行处理完显式 COMMIT 释放资源。如果还不行调大 PGA_AGGREGATE_TARGET 参数或者改用游标逐行处理。5. 进阶技巧用哈希列做等值查询兼顾安全与性能加密字段没法建索引这是硬伤。但业务上又经常需要按身份证号精确查询怎么办我的做法是加一个哈希列存 SHA256 值在这个列上建索引。-- 加哈希列 ALTER TABLE t_user_sensitive ADD id_card_hash VARCHAR2(64); -- 更新哈希值 UPDATE t_user_sensitive SET id_card_hash STANDARD_HASH(id_card, SHA256); -- 建索引 CREATE INDEX idx_id_card_hash ON t_user_sensitive(id_card_hash); -- 查询时先算哈希再匹配 SELECT * FROM t_user_sensitive WHERE id_card_hash STANDARD_HASH(110101199001011234, SHA256);STANDARD_HASH 是 Oracle 12c 才有的函数11g 及以下用 DBMS_CRYPTO.HASH 或者自定义哈希。哈希列不可逆即使泄露也无法还原明文安全性比加密列还高。但要注意加盐防止彩虹表攻击。加盐的做法是在哈希前拼一个固定字符串UPDATE t_user_sensitive SET id_card_hash STANDARD_HASH(SALT_ || id_card, SHA256);盐值同样要外置管理不能硬编码。验证方法很简单拿一条已知数据手动算哈希看是否和库里一致。如果对不上检查盐值是否一致、字符集是否一致。还有个技巧是脱敏和加密配合使用。对外展示的界面查脱敏列内部业务系统查解密列审计日志只记录哈希列。这样即使日志泄露也拿不到明文。从那以后我每次做加密迁移都强制走一遍「测试库全量验证 → 生产库分批回填 → 双写过渡 → 明文列归档」的流程再急也不跳过测试库那步。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进