SQLite 用于保密管理 - 工具和方法

引言
秘密遍布企业系统。访问关键公司资源总是需要某种类型的凭据,而这些敏感数据通常得不到充分保护。它既容易被错误地泄露,也容易被恶意利用。最佳实践很少,而且经常失败。
SQLite 是一个天然的存储平台,被美国国会图书馆批准为 长期归档介质。“SQLite 的使用量可能超过 所有其他数据库引擎的总和。”该软件经过了广泛的测试,因为它已经获得了 DO-178B 认证,以满足航空电子行业对可靠性的需求,并且 目前在空客 A350 的飞行系统中使用。 对 SQLite 的需求源于一项 损害控制应用程序,该应用程序的任务是为美国战列舰 DDG-79 奥斯卡·奥斯汀号服务。 Informix 数据库在该船上运行在 HP-UX 下,在船舶断电期间,数据库并不总是无需维护即可重新启动,这对船员造成了人身风险。 SQLite 是对这种危险的回答;如果使用得当,它将透明地从此类崩溃中恢复。 尽管 CentOS 7 中修补了少量 CVE(CVE-2015-3414、CVE-2015-3415、CVE-2015-3416、CVE-2019-13734),但很少有数据库能与 SQLite 的可靠性记录相媲美,而且没有一个在商业上流行的数据库能做到这一点。
SQLite 专门避免任何访问控制问题。它不实现其他数据库中常见的 GRANT
和 REVOKE
,而是将权限委托给操作系统。将其用于敏感数据总是需要在其上实施强大的安全性。
CyberArk Conjur 和 Summon 的免费版本构建了一个基本的保密管理平台。这些工具有些笨拙,因为 conjur
需要运行 PostgreSQL 实例,这带来了比预期更大的攻击面。将企业置于免费、集中的 conjur
和 PostgreSQL 实例之下是一个很大的风险,正如 CyberArk 的文档所证明的那样。
然而,CyberArk summon
可以配置自定义的后端提供程序,这些提供程序具有简单的接口要求。 SQLite 既适合 summon
,也适合作为独立的保密提供程序。
超越 summon
,还提供了一个需要客户端证书的 TLS 封装的网络服务。它还将配置为具有容错能力,以改善关键访问问题。
SQLite 是一个最高级别的存储平台。如果使用正确,它可以在操作系统实例恢复的情况下提供可用性。权限的操作留给用户,本文概述了在保密管理中使用它的最佳实践。
开发人员的危险
所有秘密都源于开发人员(他们可能为您的供应商工作)将它们引入运行系统时。如果这些秘密没有得到充分保护,那么它们很容易被对手收割和利用。这种风险可能是历史性的或即时的,由已经离职或仍然可用的开发人员实施。无论如何,必须识别并补救这种风险以维护安全。
EMA/Cycode 最近的一篇 白皮书 明确指出了漏洞利用的一个主要目标
开发人员是新的目标。 开发人员通常被认为是需要管理的最困难和最苛刻的技术资源之一,因为他们很少渴望遵守安全协议和控制。相反,他们专注于通过快速开发节奏交付应用程序。攻击者了解开发人员可能(或不太可能)遵循的所有流程和程序,并利用这些弱点来实现其目的。他们还知道,与典型的最终用户相比,开发人员往往拥有过多的凭据,并且通常可以访问更有价值的系统和更深层次的授权,这使他们成为高价值目标。
最终意识到 秘密已泄露到版本控制中(可见性难以控制)很少与补救凭据泄露的强烈动机相结合。 涉及生产中断的密码更改和清除存储库的尝试可能都会失败(“我们发现我们可以仅使用提交的 SHA-1 ID 从 GitHub 恢复已删除提交的完整内容……我们发现可以使用 Events API 以微不足道的努力恢复这些隐藏的提交哈希”)尽管供应商推荐的方法可以清除秘密。 这种麻木会滋生敌对利用。
威胁评估理想情况下包括对版本控制系统的详尽搜索,使用 适当的工具(或可用的最佳替代品)检查其整个历史记录中的秘密。 需要比最新的“head”修订版更深入的扫描。 忽略源代码存储库定义对威胁的不完整评估。
秘密的轮换也是企业安全的强制性目标。 密码经常被暴露,但由于生产流程的影响而无法更改。 获得轮换程序会使秘密对那些打算利用的人毫无价值。
在勒索软件接管数据中心之前,安全是成本中心。 保护企业免受这些风险是这项工作的目标。
设计注意事项
保密管理平台必须实施两个设计要务:它必须可靠,并且必须能够抵御滥用,因此需要了解 SQLite 数据库的完整性风险。
虽然 SQLite 声称自己是“零管理数据库”,但 很容易损坏,这对于关键系统来说可能是灾难性的。 在这种情况下,必须非常小心地设计 SQLite 系统。
SQLite 的一个主要方面是它在任何时候都只允许一个活动的写入者在数据库中,并且写入者总是会在某种程度上阻止读取者(第二个写入者的尝试会在命令行实用程序中导致“错误:数据库已锁定”)。 在发布时,计划了一个实验性的 并发写入功能,但它还不是生产代码。 数据操作语言(DML - 即 INSERT、UPDATE、DELETE)和任何导致写入的数据定义语言(DDL - 即 CREATE INDEX)都会阻止对所有读取者的访问。
开发人员的常见反应是启用预写日志记录(“WAL”)模式,这会改变数据库的行为,从而允许写入者和多个读取者同时访问。 SQLite 文档的各种来源中明确说明了 WAL 模式的危险
“为了加速搜索 WAL,SQLite 会在共享内存中创建一个 WAL 索引。 这提高了读取事务的性能,但使用共享内存要求所有读取者必须位于同一台机器 [和操作系统实例] 上。 因此,WAL 模式在网络文件系统上不起作用。”1
“进入 WAL 模式后无法更改页面大小。”1
“此外,WAL 模式还增加了检查点操作和用于存储 WAL 和 WAL 索引的额外文件的复杂性。”1
SQLite 不保证在 WAL 模式下使用
ATTACH DATABASE
的 ACID 一致性。 “涉及多个附加数据库的事务是原子的,前提是主数据库不是 ":memory:" 并且 journal_mode 不是 WAL。 如果主数据库是 ":memory:" 或者 journal_mode 是 WAL,那么事务将继续在每个单独的数据库文件中是原子的。 但是,如果主机在 COMMIT 中崩溃,其中更新了两个或多个数据库文件,则其中一些文件可能会获得更改,而另一些文件可能不会。”2
只有在彻底了解其局限性后才能启用预写日志记录。 在此处概述的示例中,它将不会启用。
除了 WAL 的考虑因素之外,还有一些数据库损坏和其他失败操作的危险
-
软链接和硬链接会损坏 SQLite 数据库。 对于关键系统,应提前进行检查,如果文件系统上发现数据库的软链接或多个硬链接,则中止访问。“如果单个数据库文件有多个链接(硬链接或软链接),那么这只是说明该文件有多个名称的另一种方式。 如果两个或多个进程使用不同的名称打开数据库,那么它们将使用不同的回滚日志和 WAL 文件。 这意味着如果一个进程崩溃,另一个进程将无法恢复正在进行的事务,因为它会在错误的地方查找相应的日志。”
-
写入者必须对工作目录具有写入权限,才能创建 各种临时文件,这些文件用于崩溃恢复。
-
SQLite 使用“基于成本的优化器”,对于大型凭据存储,显式的 ANALYZE 比连续启用写入连接执行的临时统计信息收集更可取。 使用 ANALYZE 的前提是可能会由于全表扫描而触发长时间的写入。 此练习留给读者完成。
-
经过大量修改后,应 VACUUM 数据库。 这可能会使所有读取者无法访问数据库。
-
SQL 绑定变量可防止“SQL 注入”。 为了避免关键安全组件中的“Bobby Tables”攻击,这些示例将使用它们。
-
由于无论其日志记录模式如何,(生产)SQLite 数据库中只允许一个写入者,因此第二次写入尝试将失败并显示 SQLITE_BUSY。 一种常见的解决方案是通过 `PRAGMA busy_timeout=5000` 形式的命令来延长等待时间。 在凭据存储中,预计写入很少,因此此处不再解决此问题。
以下将介绍这些相关的设计参数。
源代码
CyberArk 在保密代理的客户端-服务器模型中提供了 Conjur 和 Summon。 由于 Conjur Ruby 代理需要运行 PostgreSQL 实例,因此我们在此处对其进行了简短介绍。 但是,Conjur 的价值在于其能够直接与 Java 和 .NET 接口,这确实有意义(尤其是在处理臭名昭著的 .NET 中的 `web.config` 时)。 对于拥有适合 Conjur 的系统的用户,应进行分析。 遗憾的是,SQLite 的高可用性似乎未在 Conjur 中实现。
CyberArk Summon 具有一个非常 简单的界面 用于保密提供者:“它们接受一个参数,即密钥的标识符(字符串)。 如果检索成功,它们将在 stdout 上返回该值,退出代码为 0。 如果发生错误,它们将在 stderr 上返回错误消息,退出代码为非 0。”
以下分别使用 C 编译语言和 PHP 脚本语言实现了此接口。
首先,是 C 语言的版本
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <sqlite3.h> #define PERMCHECK 1 #ifdef PERMCHECK #include <sys/types.h> #include <sys/stat.h> #include <unistd.h> #endif /* cc -g -Wall -O2 -D_FORTIFY_SOURCE=2 -fstack-protector-strong -fpic -pie \ -Wl,-z,relro,-z,now -Wl,-z,now \ -o summon-sqlite summon-sqlite.c -lsqlite3 sqlite3 summon-sqlite.db \ 'CREATE TABLE secret (user TEXT UNIQUE NOT NULL, pw TEXT NOT NULL);' https://cyberark.github.io/summon/ Providers are easy to write. Given the identifier of a secret, they either return its value or an error. This is their contract: They take one argument, the identifier of a secret (a string). If retrieval is successful, they return the value on stdout with exit code 0. If errors occur, they return an error message on stderr and a non-0 exit code. */ static sqlite3 *db = NULL; void dberr(int n) { fprintf(stderr, "%s (%d)\n", sqlite3_errstr(n), n); sqlite3_close(db); exit(1); } int main(int argc, char **argv) { int index, rc; char *dbfile = getenv("DBFILE"), *Z = "/usr/local/lib/summon-sqlite/summon-sqlite.db"; sqlite3_stmt *res = NULL; if(!dbfile) dbfile = Z; #ifdef PERMCHECK { struct stat dbs; if(-1 == lstat(dbfile, &dbs)) { fprintf(stderr, "stat error\n"); exit(1); } else { if(S_ISLNK(dbs.st_mode) || 1 < dbs.st_nlink) { fprintf(stderr, "link error-%d\n", (int) dbs.st_nlink); exit(1); } if(dbs.st_mode & (S_IXUSR|S_IRWXG|S_IRWXO)) { fprintf(stderr, "perm error - chmod 600 %s\n", dbfile); exit(1); } } } #endif switch(argc) { case 2: /* read the pw */ if(!strcmp("--version", argv[1])) { printf("1.0\n"); exit(0); } /* summon -V */ if(SQLITE_OK != (rc = sqlite3_open_v2(dbfile, &db, SQLITE_OPEN_READONLY, NULL))) dberr(rc); if(SQLITE_OK != (rc = sqlite3_prepare_v2(db, "SELECT pw FROM secret WHERE user=?", -1, &res, NULL))) dberr(rc); if(SQLITE_OK != (rc = sqlite3_bind_text(res, 1, argv[1], -1, NULL))) dberr(rc); if(SQLITE_ROW == sqlite3_step(res)) printf("%s\n", sqlite3_column_text(res, 0)); /* cut by Go's Strings.TrimSpace */ else { fprintf(stderr, "no secret for %s\n", argv[1]); sqlite3_close(db); exit(1); } break; case 3: /* set the pw SQLITE_OPEN_NOFOLLOW */ if(SQLITE_OK != (rc = sqlite3_open_v2(dbfile, &db, SQLITE_OPEN_READWRITE, NULL))) dberr(rc); if(SQLITE_OK != (rc = sqlite3_prepare_v2(db, "INSERT INTO secret (user,pw) VALUES (?,?)", -1, &res, NULL))) dberr(rc); for(index = 1; index <= 2; index++) if(SQLITE_OK != (rc = sqlite3_bind_text(res, index, argv[index], -1, NULL))) dberr(rc); if(SQLITE_CONSTRAINT == (rc = sqlite3_step(res))) { sqlite3_finalize(res); if(SQLITE_OK != (rc = sqlite3_prepare_v2(db, "UPDATE secret SET pw=?2 WHERE user=?1", -1, &res, NULL))) dberr(rc); for(index = 1; index <= 2; index++) if(SQLITE_OK != (rc = sqlite3_bind_text(res, index, argv[index], -1, NULL))) dberr(rc); rc = sqlite3_step(res); } if(SQLITE_DONE != rc) dberr(rc); break; default: fprintf(stderr, "%s (user)\n", argv[0]); exit(1); } sqlite3_finalize(res); sqlite3_close(db); exit(0); }
关于此源代码的一些重要说明
-
上面,编译器指令和数据库创建 SQL 作为注释提供。 假定编译器随开发包一起提供,并且同样假定 SQLite 的 CLI 版本存在。
-
POSIX 系统调用、宏和 stat() 常量用于条件编译块中,以确保目标数据库已正确链接并且只能由所有者访问(Windows 实现将需要自定义 ACL,并且其他特殊文件系统应根据需要进行更改)。 如果需要,扩展到组权限留给读者作为练习。
-
硬链接和软链接的测试是所有 SQLite 数据库用户的最佳实践。 SQLITE_OPEN_NOFOLLOW 选项也可能在您的平台上可用(它不存在于 CentOS 7 包中)。 请注意,根据 `man 2 stat`,S_ISLNK “不在 POSIX.1-1996 中”。
-
`summon -V` 的调用将使用 `--version` 参数查询此后端,并以成功退出结束(这不能用作命名密钥)。 由于权限测试在此检查之前发生,因此数据库文件必须存在且正确,此检查才能成功。
-
否则,密码请求将作为具有 SQL 模板的绑定变量发送到数据库,而不进行动态内存分配,如果存在则返回。 请注意,UPDATE 使用显式语法重新排序绑定变量。
-
提供了辅助模式,该模式将更新存储的密钥,并且对于密码更改非常有用,可以在 SSH 上调用。 这可用于实现密钥的轮换,并且适用于批量轮换。 在较新版本的 SQLite 中,有一个 UPSERT SQL 语法可用,它可以根据存储密钥的存在情况交替插入或更新,但为了与旧版本的 SQLite 兼容(例如 CentOS 7 上存在的 SQLite),此处避免使用它,通过检测约束冲突来实现。
-
上面使用的列是显式的。 在我自己的个人使用中,还存在一个 TNS 列,用于指定 Oracle 数据库连接,或者以其他方式用于其他平台。 此处呈现的表结构非常简单,可以通过键值引擎(例如 Berkeley DB)来实现,但是可以轻松地使用其他列和约束进行扩展(这些可能包括密钥的到期日期、密钥所有者的姓名和电子邮件以及操作员的注释)。 在生产应用程序(或其 DML 等效项)中使用 `SELECT *` 永远是不合适的,因此 `INSERT` 也特定于目标列,并且如果添加了可为空的列,则不会失败。
有些人会争辩说 C 是一种不安全的语言,在安全上下文中应避免使用。 当 SQLite 用 C 实现并被信任用于关键航空电子设备时,并且此处提供的代码足够小以进行彻底审查时,很难做到这一点。 虽然 CyberArk 工具在 Ruby 和 Go 中有明显的偏好,但下面是一个等效的 PHP 后端。 CentOS 7 变体可以使用 `yum install php-cli` 加载此解释器,该命令似乎捆绑了 SQLite 连接。
#!/usr/bin/php -f <?php $dbfile = getenv("DBFILE"); $stderr = fopen('php://stderr', 'w'); if(!$dbfile) $dbfile = '/usr/local/lib/summon-sqlite/summon-sqlite.db'; if($dbs = lstat($dbfile)) { define('S_IRWXO', 7); define('S_IRWXG', 56); define('S_IXUSR', 64); if(is_link($dbfile) || $dbs['nlink'] > 1) { fprintf($stderr, "link error-%d\n", $dbs['nlink']); exit(1); } if($dbs['mode'] & (S_IXUSR|S_IRWXG|S_IRWXO)) { fprintf($stderr, "perm error - chmod 600 %s\n", $dbfile); exit(1); } } else { fprintf($stderr, "stat error\n"); exit(1); } switch($argc) { case 2: /* read the pw */ if('--version' == $argv[1]) { printf("1.0\n"); exit(0); } /* summon -V */ $db = new SQLite3($dbfile, SQLITE3_OPEN_READONLY); $st = $db->prepare('SELECT pw FROM secret WHERE user=?'); $st->bindParam(1, $argv[1], SQLITE3_TEXT); $rs = $st->execute(); $id = $rs->fetchArray(SQLITE3_ASSOC)['pw']; if(strlen($id)) printf("%s\n", $id); else { fprintf($stderr, "no secret for %s\n", $argv[1]); exit(1); } break; case 3: /* set the pw */ $db = new SQLite3($dbfile, SQLITE3_OPEN_READWRITE); $db->enableExceptions(TRUE); try { $st = $db->prepare('INSERT INTO secret (user,pw) VALUES (?,?)'); $st->bindParam(1, $argv[1], SQLITE3_TEXT); $st->bindParam(2, $argv[2], SQLITE3_TEXT); $rs = $st->execute(); } catch (Exception $error) { $st = $db->prepare('UPDATE secret SET pw=?2 WHERE user=?1'); $st->bindParam(1, $argv[1], SQLITE3_TEXT); $st->bindParam(2, $argv[2], SQLITE3_TEXT); if($rs = $st->execute()); else { fprintf($stderr, "update error\n"); exit(1); } } break; default: fprintf($stderr, "%s (user)\n", $argv[0]); exit(1); } exit(0); ?>
PHP 版本的唯一限制是假设上面的 POSIX 权限定义对于平台有效; ACL 或其他文件系统怪癖的调整留给读者作为练习。
summon
的 PHP 提供程序可能对许多用户感兴趣,因为使用它比 C 少得多繁琐的操作,并且可以使用大量的 API。
与 summon
集成
summon
实用程序作为静态编译的 Go 应用程序分发,没有任何支持库或文档。 代码存储库中有一个 docs 区域,但它似乎有些稀疏。
一个 RPM 包 可用于 summon,它很容易安装(如果 RPM 没有立即显示,请显示网页上的所有资产)
# rpm -Uvh summon_0.9.4_amd64.rpm Preparing... ################################# [100%] Updating / installing... 1:summon-0:0.9.4-1 ################################# [100%] # rpm -ql summon /usr/local/bin/summon # ls -l /usr/local/bin/summon -rwxr-xr-x. 1 root root 3503272 Aug 18 12:24 /usr/local/bin/summon # file /usr/local/bin/summon /usr/local/bin/summon: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped
我不知道为什么一个开源应用程序在没有调试符号的情况下分发(上面已剥离)。
summon
实用程序将检查 /usr/local/lib/summon
目录中的后端提供程序; 编译后的 C 必须放置在此位置。
数据库文件必须放置在其他位置,因为 summon -V
将尝试执行此目录中的所有文件,即使它们缺少执行权限(summon
真的应该使用 stat()
)。
# mkdir /usr/local/lib/summon /usr/local/lib/summon-sqlite # cd /usr/local/lib/summon-sqlite/
将源代码放置在此目录中并创建数据库。 假设您选择了 C 版本,编译二进制文件,并将其放置在 /usr/local/lib/summon
中。
# sqlite3 summon-sqlite.db \ 'CREATE TABLE secret (user TEXT UNIQUE NOT NULL, pw TEXT NOT NULL);' # cc -g -Wall -O2 -D_FORTIFY_SOURCE=2 -fstack-protector-strong -fpic -pie \ > -Wl,-z,relro,-z,now -Wl,-z,now \ > -o summon-sqlite summon-sqlite.c -lsqlite3 # mv summon-sqlite /usr/local/lib/summon/ # cd /usr/local/lib/summon
此时,将一个 众所周知 的凭据添加到您的新数据库(更正任何 stat()
错误)
# ./summon-sqlite scott tiger perm error - chmod 600 /usr/local/lib/summon-sqlite/summon-sqlite.db # chmod 600 /usr/local/lib/summon-sqlite/summon-sqlite.db # ./summon-sqlite scott tiger # ./summon-sqlite scott tiger
在工作数据库到位后,summon
实用程序能够探测此提供程序
# summon -V summon-sqlite version 1.0
该实用程序确实有一些使用信息
# summon --help NAME: summon - Parse secrets.yml and export environment variables USAGE: summon [global options] command [command options] [arguments...] VERSION: 0.9.4 COMMANDS: help, h Shows a list of commands or help for one command GLOBAL OPTIONS: -p value, --provider value Path to provider for fetching secrets -e value, --environment value Specify section/environment to parse from secrets.yaml -f value Path to secrets.yml (default: "secrets.yml") --up Go up in the directory hierarchy until the secrets file is found -D value var=value causes substitution of value to $var --yaml value secrets.yml as a literal string --ignore value, -i value Ignore the specified key if is isn't accessible or doesn't exist --ignore-all, -I Ignore inaccessible or missing keys --all-provider-versions, -V List of all of the providers in the default path and their versions(if they have the --version tag) --help, -h show help --version, -v print the version
为了触发 summon
和后端之间的密钥交换,必须创建一个配置文件
# cd /usr/local/lib # echo 'common:' > secrets.yml # echo ' ORACLE_SCOTT: !var scott' >> /usr/local/lib/secrets.yml
上面,“common”块标头可以替换为“production”、“development”或某些其他凭据集标记。 在这些部分中,环境变量与将传递到数据库的密钥相关联。
有了这些,我们就可以调用 summon
了
# summon -e common bash bash# echo $ORACLE_SCOTT tiger
summon
实用程序必须启动一个子进程,并且环境变量定义将传递给该子进程。 上面启动了一个简单的 shell 进程,但是该实用程序的目的是启动一个主要子系统,例如 docker/podman 或触发批量处理。
summon
的一个有点不方便的方面是,该实用程序在传递凭据后不会退出,而是保持空闲状态,直到子进程退出
bash# ps -ef | tail -4 root 7936 5219 0 13:35 pts/0 00:00:00 summon -f secrets.yml -e common bash root 7943 7936 0 13:35 pts/0 00:00:00 /bin/bash root 8111 7943 0 13:37 pts/0 00:00:00 ps -ef root 8112 7943 0 13:37 pts/0 00:00:00 tail -4
令人惊讶的是,summon
没有使用 execve()
系统调用来简单地更新环境,然后用子进程替换自身,而是使用 fork()
来创建它。 大量使用 summon
与许多应用程序可能导致进程表中出现此空闲父进程的多个副本。 也许与 Windows 或其他特殊平台的兼容性导致 summon
避免使用 execve()
。
请注意,如果任何子进程调用 unsetenv()
、clearenv()
或以其他方式重写环境,则用于潜在孙子进程的凭据可能会被销毁,并且需要重复运行 summon
。
还要注意,所有进程的环境变量都暴露在 /proc/*/environ
文件中,并且可以被进程所有者或 root 用户清除。 同样,命令行参数(作为 argc
和 argv[]
)对 /proc/*/cmdline
文件中的所有系统用户同样可见,这意味着数据库密钥也通过设计暴露。 由于这些原因,参数和环境变量传递密钥都不是最佳选择。
应演示后端的密码更改功能,因此在此处更改凭据
# /usr/local/lib/summon/summon-sqlite scott panther # summon -e common bash bash# echo $ORACLE_SCOTT panther
应该注意的是,此配置中 summon
安全性的主要驱动因素是数据库文件的权限,该权限由后端强制执行。 尝试在此后端上调用 summon
的非 root 用户将失败。 更改数据库文件的所有权会将此权限传递给特定的命名用户。
传递环境变量需要做很多工作,有些人可能会对架构中的几个点提出质疑。 这促使人们反思在这些约束之外可以实现什么。
独立使用
C 代码可以被适配为一个独立的网络服务,运行在 inetd 接口下,该接口将标准输入/输出重定向到远程套接字。虽然 PHP 也可以这样使用,但它引入的攻击面比首选的更大。
stunnel
实用程序也能够实现 inetd 接口,但可以对 TLS 实施非常严格的访问控制,需要客户端证书。值得注意的是,CentOS 7 stunnel
RPM 的更新日志中没有提及已发布的 CVE。虽然 stunnel
基于 OpenSSL,必须积极地进行补丁,但 stunnel
包缺乏 CVE 令人感到放心。虽然 stunnel 网站 以快速的速度发布新版本并带有严重的警告,但我们必须信任 RedHat 的向后移植努力,以确保遗留版本的安全性。
以下是经过修改的代码,它移除了密码更改功能,并从标准输入安全地读取数据库密钥,而不是从 argv[1]
(这样可以避免在 /proc
中暴露密钥)
# cat SQLsecret.c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sqlite3.h> /* cc -g -Wall -O2 -D_FORTIFY_SOURCE=2 -fstack-protector-strong -fpic -pie \ -Wl,-z,relro,-z,now -Wl,-z,now -o SQLsecret SQLsecret.c -lsqlite3 */ #define BUF 1024 #define PERMCHECK 1 #ifdef PERMCHECK #include <sys/types.h> #include <sys/stat.h> #include <unistd.h> #endif static sqlite3 *db = NULL; void dberr(int n) { printf("**%s (%d)\n", sqlite3_errstr(n), n); sqlite3_close(db); exit(0); } int main(int argc, char **argv) { int rc; char key[BUF], *dbfile = getenv("DBFILE"), *Z = "/etc/stunnel/SQLsecret.db"; sqlite3_stmt *res = NULL; if(!dbfile) dbfile = Z; #ifdef PERMCHECK { struct stat dbs; if(-1 == lstat(dbfile, &dbs)) { printf("**stat error\n"); exit(0); } else { if(S_ISLNK(dbs.st_mode) || 1 < dbs.st_nlink) { fprintf(stderr, "**link error-%d\n", (int) dbs.st_nlink); exit(1); } if(dbs.st_mode & (S_IXUSR|S_IRWXG|S_IRWXO)) { printf("perm error - chmod 600 %s\n", dbfile); exit(0); } } } #endif if(fgets(key, BUF, stdin) == key) { char *p = strrchr(key, '\n'); if(p) *p = 0; } else { printf("**read error\n"); exit(0); } if(SQLITE_OK != (rc = sqlite3_open_v2(dbfile, &db, SQLITE_OPEN_READONLY, NULL))) dberr(rc); if(SQLITE_OK != (rc = sqlite3_prepare_v2(db, "SELECT pw FROM secret WHERE user=?", -1, &res, NULL))) dberr(rc); if(SQLITE_OK != (rc = sqlite3_bind_text(res, 1, key, -1, NULL))) dberr(rc); if(SQLITE_ROW == sqlite3_step(res)) printf("%s\n", sqlite3_column_text(res, 0)); else printf("**no secret for %s\n", key); sqlite3_close(db); exit(0); }
假设源代码位于 stunnel
配置目录中,编译代码,并调整 SELinux 安全上下文(如果您使用的是 CentOS 或其衍生版本)。请注意,SELinux 重新标记事件会清除此权限,因此如果需要,请生成永久规则。
# cd /etc/stunnel # cc -g -Wall -O2 -D_FORTIFY_SOURCE=2 -fstack-protector-strong -fpic -pie \ -Wl,-z,relro,-z,now -Wl,-z,now -o SQLsecret SQLsecret.c -lsqlite3 # chcon -t stunnel_exec_t SQLsecret
放置数据库并用已知的用户填充它,确认它以 root 身份运行正常且具有正确的权限,然后以非 root 用户身份测试访问权限
$ /etc/stunnel/SQLsecret scott **unable to open database file (14)
此时,TLS 包装器需要 RSA 密钥/证书对。创建一个并使用以下命令锁定权限
# cd /etc/stunnel # openssl req -newkey rsa:4096 -x509 -days 3650 -nodes \ -out SQLsecret.pem -keyout SQLsecret.pem Generating a 4096 bit RSA private key .....................................................................................................................................................................................++ ...++ writing new private key to 'SQLsecret.pem' ----- You are about to be asked to enter information that will be incorporated into your certificate request. What you are about to enter is what is called a Distinguished Name or a DN. There are quite a few fields but you can leave some blank For some fields there will be a default value, If you enter '.', the field will be left blank. ----- Country Name (2 letter code) [XX]:US State or Province Name (full name) []:IL Locality Name (eg, city) [Default City]:Chicago Organization Name (eg, company) [Default Company Ltd]:SQLsecret Organizational Unit Name (eg, section) []:stunnel Common Name (eg, your name or your server's hostname) []:netcat Email Address []:SQLsecret@foobar # chmod 400 SQLsecret.pem
配置一个 stunnel
控制文件
# cat SQLsecret.conf #GLOBAL################################################################## sslVersion = TLSv1.2 TIMEOUTidle = 300 renegotiation = no FIPS = no options = NO_SSLv2 options = NO_SSLv3 options = SINGLE_DH_USE options = SINGLE_ECDH_USE options = CIPHER_SERVER_PREFERENCE syslog = yes debug = debug # setuid = adm # setgid = adm # chroot = /var/empty libwrap = no service = SQLsecret ; cd /var/empty; mkdir -p stunnel/etc; cd stunnel/etc; ; echo 'SQLsecret: ALL EXCEPT localhost' >> hosts.deny; ; chcon -t stunnel_etc_t hosts.deny ; https://hynek.me/articles/hardening-your-web-servers-ssl-ciphers/ ciphers=ECDH+AESGCM:ECDH+CHACHA20:DH+AESGCM:RSA+AESGCM:!aNULL:!MD5:!DSS curve = secp521r1 #CREDENTIALS############################################################# verify = 4 CAfile = /etc/stunnel/SQLsecret.pem cert = /etc/stunnel/SQLsecret.pem #ROLE#################################################################### exec = /etc/stunnel/SQLsecret
以下文件现在应该存在于 /etc/stunnel
中(仔细检查密钥的权限)
$ ll /etc/stunnel/SQLsecret* -rwxr-xr-x. 1 root root 20472 Sep 23 11:11 /etc/stunnel/SQLsecret -rw-r--r--. 1 root root 1588 Sep 23 11:11 /etc/stunnel/SQLsecret.c -rw-r--r--. 1 root root 967 Sep 22 14:59 /etc/stunnel/SQLsecret.conf -rw-------. 1 root root 12288 Sep 23 12:03 /etc/stunnel/SQLsecret.db -r--------. 1 root root 6221 Sep 22 14:10 /etc/stunnel/SQLsecret.pem
为了隐藏此代码的存在,当前配置下可以安全地 chmod 700 /etc/stunnel
(在某些情况下这可能会有问题)。
假设 systemd 将用于套接字激活,请选择一个端口(其他 init 系统可以求助于经典的 inetd)
# cat /etc/systemd/system/SQLsecret.socket [Unit] Description=SQLsecret [Socket] ListenStream=34251 Accept=yes [Install] WantedBy=sockets.target
配置 stunnel
以便在接受连接时启动
# cat /etc/systemd/system/SQLsecret@.service [Unit] Description=SQLsecret [Service] ExecStart=-/usr/bin/stunnel /etc/stunnel/SQLsecret.conf StandardInput=socket
启动监听器。要在每次系统启动时启动它,请启用它。
# systemctl enable SQLsecret.socket Created symlink from /etc/systemd/system/sockets.target.wants/SQLsecret.socket to /etc/systemd/system/SQLsecret.socket. # systemctl start SQLsecret.socket
使用能够同时提供客户端证书和 TLS 1.2 协议的 SSL 客户端来打开隧道
# openssl s_client -quiet -cert SQLsecret.pem -connect localhost:34251 depth=0 C = US, ST = IL, L = Chicago, O = SQLsecret, OU = stunnel, CN = netcat, emailAddress = SQLsecret@foobar verify error:num=18:self signed certificate verify return:1 depth=0 C = US, ST = IL, L = Chicago, O = SQLsecret, OU = stunnel, CN = netcat, emailAddress = SQLsecret@foobar verify return:1 scott tiger # openssl s_client -quiet -connect localhost:34251 depth=0 C = US, ST = IL, L = Chicago, O = SQLsecret, OU = stunnel, CN = netcat, emailAddress = SQLsecret@foobar verify error:num=18:self signed certificate verify return:1 depth=0 C = US, ST = IL, L = Chicago, O = SQLsecret, OU = stunnel, CN = netcat, emailAddress = SQLsecret@foobar verify return:1 140139609302928:error:14094410:SSL routines:ssl3_read_bytes:sslv3 alert handshake failure:s3_pkt.c:1493:SSL alert number 40 140139609302928:error:140790E5:SSL routines:ssl23_write:ssl handshake failure:s23_lib.c:177: # openssl s_client -quiet -cert SQLsecret.pem -connect localhost:34251 2>/dev/null scott tiger # nc --ssl --ssl-key=/etc/stunnel/SQLsecret.pem --ssl-cert=/etc/stunnel/SQLsecret.pem localhost 34251 scott tiger Ncat: Input/output error. # nc --ssl localhost 34251 Ncat: Input/output error.
上面,我们使用了 netcat 和 OpenSSL s_client 与我们的服务进行交互。如果它们不提供证书(也包含密钥),两者都会失败。假设管理员熟悉 TLS 证书的积极保护。可以通过将标准错误重定向到 null 来静默 s_client 状态消息。Netcat 能够以交互方式获取 tiger 密码,但在批量使用时总是报告错误并失败。在 POSIX shell 的上下文中,下面将采用 s_client 选项
# echo scott | openssl s_client -quiet -cert SQLsecret.pem -key SQLsecret.pem -connect localhost:34251 2>/dev/null tiger # var=$(echo scott | openssl s_client -quiet -cert SQLsecret.pem -key SQLsecret.pem -connect localhost:34251 2>/dev/null) # echo $var tiger # get_secret () { printf %s\\n "$1" | openssl s_client -quiet -cert SQLsecret.pem -key SQLsecret.pem -connect localhost:34251 2>/dev/null; return $?; } # get_secret scott tiger
请注意,上面的 shell 函数不会将 argv[]
暴露给 /proc
。
不鼓励在 POSIX shell 中使用 echo
;在生产脚本中避免使用它。虽然 fgets()
标准库函数应该读取直到 EOF 或换行符,但在尝试使用 printf 而不使用换行符时,POSIX 脚本会返回 null,因此请包含 \\n。
重要的是要注意,s_client 将其成功或失败的状态返回到 shell,我们可以根据此信息采取行动。使用对监听端口和我们未配置的端口的调用来检查 shell 的返回状态
# printf scott\\n | openssl s_client -quiet -cert SQLsecret.pem -connect localhost:34251 2>/dev/null tiger # echo $? 0 # printf scott\\n | openssl s_client -quiet -cert SQLsecret.pem -connect localhost:34252 2>/dev/null # echo $? 1
shell 的成功意味着零,失败是非零。考虑在 POSIX shell 中实现容错,超过一组三个 stunnel
服务器
#!/bin/sh set -u r=1 until [ 0 -eq "$r" ] do for host in secret1.myco.com secret2.myco.com secret3.myco.com do var=$(printf %s\\n "$1" | openssl s_client -quiet -cert SQLsecret.pem \ -connect "$host":34251 2>/dev/null) r=$? case "$var" in [*][*]*) r=1;; esac [ 0 -eq "$r" ] && break done done printf %s\\n "$var"
该脚本将循环访问所有 stunnel
服务器,直到返回成功的数据库查找。不仅重试 s_client TLS 错误,而且任何数据库错误也会强制失败,以便在下一个服务器上重试,因此由于写入者引起的 SQLITE_BUSY 错误不再是致命的。请注意,查找不存在的用户会将脚本推入无限循环
# ./getsecret.sh scott tiger # ./getsecret.sh foo bar # ./getsecret.sh bar (never returns)
上面没有导出环境数据,最大限度地减少了在 /proc
中的可见性。可能可以在内存转储中找到凭据(可能涉及 /proc/*/mem
),但这大大提高了凭据存储攻击的复杂性。覆盖密钥并在使用后将其从内存中删除的代码是一种最佳实践。
可以通过多种方法实现轮换。可以将数据库网络挂载到所有冗余服务器上,发出 ATTACH 指令,然后开始事务以原子地和全局地更新密钥。这是危险的,因为大多数网络挂载都是明文的;SMB 需要特定的指令才能加密,而 NFS 是明文的,除非 强制通过 stunnel。使用 sshfs 的挂载也可能保留事务完整性。这与任何处于 WAL 模式的数据库都不兼容。
没有 ACID 保证的轮换可能更容易,并且可以通过我之前的 ssh-run 脚本批量实现。
轮换后,管理员可能会考虑将所有数据库文件设置为只读权限,并且仅在准备好推送更改时启用写入访问权限。
可以通过配置 stunnel
来实现 chroot()
并释放 root 权限来扩展安全性。还可以启用 libwrap 功能,以允许从有限的一组 IP 地址或 DNS 名称进行访问。上面 stunnel
配置文件中的注释解决了这样做的方法。
显然,s_client 不是将密钥注入运行时环境的理想代理。它在这里用于 POSIX shell 的上下文中,但复杂的框架可能具有 TLS 1.2 功能作为安全通信和冗余的最低要求。对于 POSIX shell 应用程序,使用 s_client 似乎足够了,但可以很容易地找到其他选项。
结论
本文的动机涉及凭据泄露事件,每个经验丰富的管理员都非常熟悉这些事件。这些经历是不受欢迎的、处理方式不一致的、压力很大的,并且很少有富有成效的结果。最好避免它们,但这种避免需要坚定的承诺和创造力。最佳实践尚未成熟,并且并非所有企业都能免受这些事件的困扰。
Ruby 和 Go 的使用是有问题的。我们现代的内核是用 C 编写的,这是无可争议的,坚持使用垃圾收集语言似乎经常导致功能减少的次优解决方案。虽然我们可能会感叹工具中的许多陷阱,但我们不能放弃在理想主义的实践中暴露的安全控制。
数据库中的明文是一个问题,其他秘密管理工具的提供商会加密存储。这可以在此处提供的代码中实现,但即使没有此类保护,这仍然比许多扩散到版本控制中的 web.config
文件或类似的暴露情况好得多。在需要时,可以使用 LibTomCrypt、 LibSodium 或 OpenSSL(为了最大程度的可移植性)来实现 SQLite 存储加密。SQLite 扩展也可能提供 另一种选择。
由于我完全处于 US7ASCII 的范围内,因此此处未解决 Unicode 问题,但此编码中没有任何内容可以阻止在支持的平台上使用 UTF-8。如果出现问题, C 中的解决方案 当然存在。
可以肯定的是,现有工具的可用性和覆盖范围不足。也许可以解决这个问题。