接手一个有些年头的项目,最怕看到这种东西:main方法里、几个@Configuration类里、甚至某个工具类的静态块里,散落着一排System.setProperty("xxx.password", "明文口令")。一次内部安全扫描下来,这些全被标记成硬编码凭据,整改单直接压到开发头上。第一反应自然是加密,把明文换成ENC(...)密文,问题看起来就解决了。但真动手才发现,System.setProperty写进去的是密文,Spring 容器里@Value、@ConfigurationProperties、Environment.getProperty读出来的还是密文,解密逻辑根本没有一个合适的落脚点。这篇就围绕这个具体场景,把System.setProperty(key, value)设置的系统属性在 Spring Boot 里怎么被读取、加解密应该在哪一层介入、以及几种实现路径各自的边界,完整拆一遍。
1. System.setProperty 写进去的值,Spring 从哪条路读到
1.1 propertySources 的遍历顺序决定了谁覆盖谁
很多人以为application.yml里配的值优先级最高,实际上在 Spring Boot 的默认环境里,System.setProperty写进去的值优先级比配置文件高。ConfigurableEnvironment内部维护一个MutablePropertySources,它是个有序列表,getProperty就是从头到尾遍历,第一个返回非 null 的源胜出。标准StandardEnvironment初始化时依次放入的是systemProperties、systemEnvironment,Spring Boot 后面再追加random、配置文件源等。也就是说,同名 key 在application.yml和System.setProperty里都出现时,后者赢。
这个顺序带来的第一个直接结论是:你不需要去改配置文件,只要在 JVM 里设了系统属性,配置文件的对应项就被压住了。第二个结论更关键——想要拦截读取行为,必须把解密逻辑放在systemProperties这个源「前面」或者「里面」,放在后面没有任何意义,因为遍历到它的时候值已经被取走了。
顺带说一个经常被忽略的点:SpringApplication.setDefaultProperties(...)设置的默认属性是列表里优先级最低的一档,比配置文件还低。有些团队把密钥相关的默认值塞在这里,结果被任何一处同名配置覆盖,排查半天才发现是优先级问题。默认属性适合放「兜底值」,不适合放「必须生效的值」。
1.2 systemProperties 这个源持有的就是 System.getProperties() 的引用
这是整个方案能不能成立的技术前提,值得单独拎出来说。StandardEnvironment构造时执行的代码大致是propertySources.addLast(new PropertiesPropertySource("systemProperties", getSystemProperties())),而getSystemProperties()返回的正是System.getProperties()那个Properties对象本身。PropertiesPropertySource继承自MapPropertySource,构造时把传入的Properties当作Map直接持有引用,中间没有做任何拷贝。
意味着什么?你在Environment构建之后,直接对System.getProperties()里的某个 key 调setProperty覆盖成明文,Environment.getProperty立刻就能读到明文。反过来也一样,你通过System.setProperty改值,Environment那边看到的也是新值。两者是同一份数据,只是套了两层不同的访问接口。
我第一次确认这一点是写了个最小复现:SpringApplication.run之后在CommandLineRunner里调System.setProperty("foo", "bar"),再environment.getProperty("foo"),能拿到bar。这个行为在 Spring Boot 2.x 和 3.x 上一致。它意味着「原地改写」是一条技术上完全可行的路,不需要额外包装任何东西,改动量极小。
1.3 @Value、@ConfigurationProperties、Binder 三条路径读的不是同一个东西
同样是读一个属性,三条常见路径的机制完全不同,拦截点也就不同。
@Value("${app.secret}")走的是AbstractBeanFactory.resolveEmbeddedValue,它会依次调用注册在BeanFactory上的embeddedValueResolvers。默认那个由PropertySourcesPlaceholderConfigurer注册,内部持有Environment的PropertySources引用去查值。所以只要Environment里能查到明文,@Value就能注入明文。
@ConfigurationProperties走的是ConfigurationPropertiesBindingPostProcessor,它通过ConfigurationPropertySources.get(environment)拿到一个适配后的迭代器,再交给Binder去绑定。这个适配层叫ConfigurationPropertySources,在ConfigDataEnvironmentPostProcessor执行时通过ConfigurationPropertySources.attach(environment)挂到Environment上,内部同样持有MutablePropertySources的引用。所以只要源在列表里,Binder也能看到。
第三条路径是业务代码里直接System.getProperty("app.secret")。这条路径完全绕开 Spring,Environment里做了什么都跟它无关。这是后面方案选择时最重要的分水岭:如果你的项目里存在直接调System.getProperty的老代码,包装型方案就兜不住,只能走原地改写。
2. 三种介入位置的取舍与能力边界
2.1 原地改写 System.getProperties():一次解密,全链路可见
最直接的思路是在 Spring 启动早期,遍历System.getProperties(),把所有符合ENC(...)格式的值解密后原地写回。因为Properties本身就是Hashtable的子类,setProperty是线程安全的,而在EnvironmentPostProcessor阶段还没有任何业务线程在并发读这些值,所以不存在并发问题。
这个方案的优势非常明显:只解密一次,没有重复计算开销;@Value、@ConfigurationProperties、Binder、Environment.getProperty、甚至业务代码里裸调的System.getProperty,全部都能拿到明文。相当于在属性进入 Spring 视野之前就把密文「翻译」掉了,后面所有读取方都是无感知的。
代价也要说清楚。第一,解密后的明文会长时间驻留 JVM 内存,直到进程结束,虽然加密前它原本也是明文,但这一点在安全评审时会被问到,需要有话术准备。第二,属性名和属性值都还在System.getProperties()里,jcmd <pid> VM.system_properties这类工具仍然能把它们 dump 出来,只是看到的是明文而不是密文——严格来说安全性并没有因为加密而提升,加密解决的是「静态存储时是明文」这个合规问题,不是运行时内存保护。第三,改写发生在启动早期,如果解密失败(密钥不对、密文被截断),进程会直接起不来,错误信息必须足够清晰,否则运维会很难受。
从工程实践看,这个方案适合绝大多数场景,尤其是那些历史包袱重、业务代码里存在System.getProperty直调的老项目。
2.2 包装一个 DecryptingPropertySource 并 addFirst
第二种思路是不改System.getProperties()本身,而是在Environment的MutablePropertySources最前面插一个自定义PropertySource,读取时如果发现值是ENC(...)就解密返回。核心代码大致是这样:
public class DecryptingPropertySource extends PropertySource<Properties> { private static final String ENC_PREFIX = "ENC("; private static final String ENC_SUFFIX = ")"; public DecryptingPropertySource(String name, Properties source) { super(name, source); } @Override public Object getProperty(String name) { String raw = getSource().getProperty(name); if (raw == null) { return null; } String trimmed = raw.trim(); if (!trimmed.startsWith(ENC_PREFIX) || !trimmed.endsWith(ENC_SUFFIX)) { return raw; } String payload = trimmed.substring(ENC_PREFIX.length(), trimmed.length() - ENC_SUFFIX.length()); return AesGcmCipher.decrypt(payload, MasterKeyHolder.get()); } }注册位置在EnvironmentPostProcessor里:
environment.getPropertySources().addFirst( new DecryptingPropertySource("decryptSystemProperties", System.getProperties()));注意必须用addFirst,而不是addLast。原因在 1.1 已经说过,遍历是从前往后,插在systemProperties后面就等于永远不会被命中——systemProperties先返回了密文,遍历直接结束。
这个方案比原地改写「干净」一些,System.getProperties()本身没被污染,任何还想去读原始密文的地方(比如某个审计组件)拿到的还是密文。但它有一个致命的缺口:直接调System.getProperty的代码拿不到解密后的值。而且每次读取都要做一次解密,虽然有 AES 加持开销不大,但高频读取的属性(比如某些按请求读取的配置)会累积成可观测的 CPU 消耗,通常需要加一层ConcurrentHashMap缓存,缓存又会引入密钥轮换时要清缓存的问题,复杂度上来了。
2.3 给 BeanFactory 挂 embeddedValueResolver 只能兜住 @Value
还有一种是往ConfigurableBeanFactory上追加embeddedValueResolver,想在@Value解析那一步把密文换掉。这条路我不推荐,原因有两个。
第一个是顺序问题。AbstractBeanFactory.resolveEmbeddedValue是按注册顺序依次调用的,第一个返回非 null 的直接返回。默认的PropertySourcesPlaceholderConfigurer注册的解析器会处理${...},如果它已经返回了密文,你后面追加的解析器根本没机会执行。想插到前面去,就得先clear再重置,容易把框架默认行为搞坏。第二个是覆盖面问题,这条路只能影响@Value,@ConfigurationProperties走的是Binder,完全不走embeddedValueResolver,一点用都没有。
一个典型的坑场景是:某个配置类用@Value注入了数据库密码,解密正常;隔壁一个@ConfigurationProperties(prefix = "app.datasource")的类绑同一个 key,拿到的却是密文,连接池初始化时报密码错误。所以这条路只适合「全项目只有@Value、没有@ConfigurationProperties」的极小场景,不具备通用性。
2.4 三种方案的能力矩阵对比
把三种方案放到一张表里看更直观:
| 介入位置 | @Value 能否拿到明文 | @ConfigurationProperties 能否拿到明文 | 直接 System.getProperty 能否拿到明文 | 运行时开销 | 实现复杂度 |
|---|---|---|---|---|---|
| 原地改写 System.getProperties() | 能 | 能 | 能 | 启动时一次性 | 低 |
| 包装源 + addFirst | 能 | 能 | 不能 | 每次读取一次 | 中 |
| 追加 embeddedValueResolver | 能 | 不能 | 不能 | 每次解析一次 | 中高 |
看清楚这张表,选型基本就定了。项目里只要出现过一次System.getProperty直调,原地改写就是唯一稳妥的选择;如果代码规范里明确禁用了直调且能保证长期不被破坏,包装源方案在「不改全局状态」这一点上更讨喜。我自己的项目里一般用原地改写,理由是历史代码不可控,而且实现真的短。
3. 从密文到明文:完整实现拆解
3.1 密文格式与算法参数怎么定
先约定密文的外层格式:ENC(<Base64 载荷>)。选这个格式有几点考虑。ENC(前缀让「这段是不是密文」的判断变成一次startsWith,成本极低;用圆括号而不是大括号,是为了避免和 Spring 的${...}占位符语法在视觉上混淆,虽然实际不影响解析,但人看着会犯嘀咕;外层包裹让明文和密文能在同一份配置里共存,不至于一刀切。
Base64 一定要用标准编码器Base64.getEncoder(),不要图省事用 URL-safe 变体去省/和+的转义。原因在 4.4 会展开,标准 Base64 的字符集是A-Za-z0-9+/=,里面没有逗号,这一点对@ConfigurationProperties绑定List<String>很关键。
算法选 AES-256-GCM,工作模式AES/GCM/NoPadding,IV 长度 12 字节,认证标签长度 128 位。GCM 是带认证的加密模式,密文被篡改时解密会抛AEADBadTagException,比 CBC 那种「改了也能解出垃圾」的模式安全得多,而且不需要额外做完整性校验。IV 每次加密随机生成并拼在密文前面,绝不重复使用——GCM 模式下 IV 复用会直接导致密钥流复用,是密码学上非常严重的问题。
载荷结构是[12 字节 IV][密文][16 字节 Tag],整体 Base64 编码。解密时先取前 12 字节当 IV,剩下的交给doFinal处理,Tag 由 JCE 内部截取。
3.2 密钥从哪里来,为什么不能写在 application.yml
密钥不能和密文待在一起,这是加密方案是否成立的分界线。密文放在系统属性里,密钥如果再放在同一个application.yml里,攻击者拿到配置文件就同时拿到了密文和解密方法,整个加密就变成了纯装饰。
合理的密钥来源顺序是:环境变量优先,密钥文件兜底。环境变量在容器编排里最顺手,Kubernetes的Secret可以直接挂成环境变量;密钥文件适合虚拟机部署,文件权限设成600,属主是运行应用的那个账户。两者都没有就启动失败,绝不能有「默认密钥」这种兜底逻辑,那是比不加密更糟的做法。
密钥本身必须是 32 字节(256 位)的随机字节,Base64 编码后存进环境变量。千万不能用口令字符串直接getBytes()当密钥,那样熵太低,口令的字符分布会让暴力破解的成本大幅下降。正确的做法是用openssl rand -base64 32之类的工具生成真随机字节。
public final class MasterKeyHolder { private static final String ENV_NAME = "APP_MASTER_KEY"; private static final String KEY_FILE_PROPERTY = "app.crypto.key-file"; private static final int KEY_BYTES = 32; private static volatile SecretKey cached; private MasterKeyHolder() { } public static SecretKey get() { SecretKey local = cached; if (local != null) { return local; } synchronized (MasterKeyHolder.class) { if (cached == null) { cached = load(); } return cached; } } private static SecretKey load() { String raw = System.getenv(ENV_NAME); if (raw == null || raw.isBlank()) { String path = System.getProperty(KEY_FILE_PROPERTY); if (path == null || path.isBlank()) { throw new IllegalStateException( "主密钥未配置:请设置环境变量 " + ENV_NAME + " 或系统属性 " + KEY_FILE_PROPERTY); } raw = readKeyFile(path); } byte[] bytes = Base64.getDecoder().decode(raw.trim()); if (bytes.length != KEY_BYTES) { throw new IllegalStateException("主密钥长度必须为 256 位,当前为 " + bytes.length * 8 + " 位"); } return new SecretKeySpec(bytes, "AES"); } private static String readKeyFile(String path) { try { return Files.readString(Path.of(path), StandardCharsets.UTF_8).trim(); } catch (IOException e) { throw new IllegalStateException("读取主密钥文件失败: " + path, e); } } }注意KEY_FILE_PROPERTY这个系统属性本身存的是密钥文件的路径,不是密钥内容,即使被jcmddump 出来也只暴露路径,风险可控。密钥文件的内容通过文件系统权限保护。
3.3 AES-GCM 加解密工具类
工具类要处理两件事:加解密本身,以及把异常转换成语义清晰的运行时异常。密码学的GeneralSecurityException对业务方来说毫无意义,包装成「密钥不匹配」「密文损坏」这类信息才有排查价值。
public final class AesGcmCipher { private static final String TRANSFORMATION = "AES/GCM/NoPadding"; private static final int IV_LENGTH = 12; private static final int TAG_LENGTH_BIT = 128; private static final SecureRandom RANDOM = new SecureRandom(); private AesGcmCipher() { } public static String encrypt(String plainText, SecretKey key) { try { byte[] iv = new byte[IV_LENGTH]; RANDOM.nextBytes(iv); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BIT, iv)); byte[] cipherBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); byte[] payload = new byte[iv.length + cipherBytes.length]; System.arraycopy(iv, 0, payload, 0, iv.length); System.arraycopy(cipherBytes, 0, payload, iv.length, cipherBytes.length); return Base64.getEncoder().encodeToString(payload); } catch (GeneralSecurityException e) { throw new IllegalStateException("属性加密失败", e); } } public static String decrypt(String base64Payload, SecretKey key) { byte[] payload; try { payload = Base64.getDecoder().decode(base64Payload); } catch (IllegalArgumentException e) { throw new IllegalStateException("密文不是合法的 Base64 编码", e); } if (payload.length <= IV_LENGTH) { throw new IllegalStateException("密文长度不足,可能被截断"); } try { GCMParameterSpec spec = new GCMParameterSpec(TAG_LENGTH_BIT, payload, 0, IV_LENGTH); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, key, spec); byte[] plain = cipher.doFinal(payload, IV_LENGTH, payload.length - IV_LENGTH); return new String(plain, StandardCharsets.UTF_8); } catch (AEADBadTagException e) { throw new IllegalStateException("属性解密失败:密钥不匹配或密文已被篡改", e); } catch (GeneralSecurityException e) { throw new IllegalStateException("属性解密失败", e); } } }AEADBadTagException单独捕获并给出明确提示,是因为实际运维中九成的解密失败都是「环境用错密钥」导致的——测试环境的密文拿到生产环境用生产的密钥解,必然抛这个。把这个信息前置到异常消息里,能省掉大量沟通成本。
3.4 EnvironmentPostProcessor 的实现与注册差异
到了把上面这些东西串起来的时候。EnvironmentPostProcessor是 Spring Boot 提供的钩子,在Environment构建完成、ApplicationContext还没创建之前执行,正好是原地改写的窗口期。
public class SystemPropertyDecryptPostProcessor implements EnvironmentPostProcessor, Ordered { private static final String ENC_PREFIX = "ENC("; private static final String ENC_SUFFIX = ")"; @Override public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { Properties props = System.getProperties(); int decrypted = 0; for (String name : props.stringPropertyNames()) { String raw = props.getProperty(name); if (raw == null) { continue; } String trimmed = raw.trim(); if (!trimmed.startsWith(ENC_PREFIX) || !trimmed.endsWith(ENC_SUFFIX)) { continue; } String payload = trimmed.substring(ENC_PREFIX.length(), trimmed.length() - ENC_SUFFIX.length()); try { props.setProperty(name, AesGcmCipher.decrypt(payload, MasterKeyHolder.get())); decrypted++; } catch (RuntimeException e) { throw new IllegalStateException("系统属性 [" + name + "] 解密失败", e); } } if (decrypted > 0) { System.out.println("[crypto] 已解密 " + decrypted + " 个系统属性"); } } @Override public int getOrder() { return Ordered.HIGHEST_PRECEDENCE + 30; } }有几个细节需要说明。stringPropertyNames()返回的是一个新的HashSet快照,所以在遍历过程中修改底层Properties是安全的,不会抛ConcurrentModificationException。getOrder()的取值要排在ConfigDataEnvironmentPostProcessor之后,这样配置文件也已经加载了,虽然这个处理器本身不处理配置文件里的密文,但顺序靠后能避免和其他后处理器抢跑。
注册方式在 Spring Boot 2.x 和 2.7 之后有区别。2.x 用META-INF/spring.factories:
org.springframework.boot.env.EnvironmentPostProcessor=\ com.example.crypto.SystemPropertyDecryptPostProcessor2.7 开始引入了新的.imports文件写法,Spring Boot 3.x 推荐这种:
META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor.imports文件内容就是类名,一行一个:
com.example.crypto.SystemPropertyDecryptPostProcessor注意这个EnvironmentPostProcessor的实例不是 Spring Bean,它由SpringFactoriesLoader或ImportCandidates反射创建,执行时机比ApplicationContext早得多。想在处理器里注入Environment以外的东西,得自己从application参数上取,或者直接在处理器里new。这一点和ApplicationRunner之类的完全不一样,第一次用的人容易在这里卡住。
3.5 生成密文的独立命令入口
有了工具类,生成密文本身很简单,但一定要做成独立的命令入口,而不是在业务代码里随手调encrypt。原因很实际:加密动作应该发生在配置准备阶段,由运维或发布流程执行,业务运行时期不应该有加密调用。
public final class EncryptCli { public static void main(String[] args) { if (args.length != 1) { System.err.println("用法: java -cp app.jar com.example.crypto.EncryptCli <明文>"); System.exit(2); } String cipher = AesGcmCipher.encrypt(args[0], MasterKeyHolder.get()); System.out.println("ENC(" + cipher + ")"); } }执行前需要把APP_MASTER_KEY环境变量设好,输出一行ENC(...),直接贴到启动脚本或者配置里使用。可以让这个类在打包时排除出去(放到独立的 tools 模块),避免生产包里带着加密入口,减少攻击面。
4. 真正跑起来才会暴露的几个问题
4.1 在 @PostConstruct 里设属性,@ConfigurationProperties 已经绑完了
这是个很隐蔽的时序坑。假设有个类想在启动时从某个外部源拉取配置再System.setProperty,很自然会写成@PostConstruct方法。问题是@ConfigurationProperties的绑定发生在ConfigurationPropertiesBindingPostProcessor.postProcessBeforeInitialization,它在@PostConstruct之前执行,而所有的@ConfigurationPropertiesBean 在容器刷新阶段基本都已经绑定完成了。你在@PostConstruct里设的属性,那些已经绑好的 Bean 是看不到的。
正确的做法是把这类「需要动态决定」的属性设置放到更早的位置——EnvironmentPostProcessor、`ApplicationContextInitializer