bigywater.tech
Java · 414

Java 安全审计日志:记录动作而不是秘密

审计日志要回答“谁在什么时候做了什么”,不能把秘密也记下来,也不能被悄悄改掉。这篇用真实运行量出:JDK 自带的调试日志与日常的字符串会泄露什么,哈希链能防什么、防不住什么,以及 JFR 里的安全事件长什么样。

1 次
开启 javax.net.debug=ssl:record:plaintext 时,日志里能还原出 Authorization 头(SECRET-TOKEN-123)的次数(默认调试级别是 0 次)
991
删除最后 10 条审计记录后,哈希链用外部锚点校验失败的位置(无锚点时校验全部通过)
0 次
整个 JFR 文件的 TLS / X509 事件里请求中的秘密(SECRET-TOKEN-123、hunter2)出现的次数

调试日志

实测 · 本机执行输出
  [默认(没有调试)] 输出 1 行,其中十六进制转储的字节 0 个;直接搜索文本:“SECRET-TOKEN-123” 出现 0 次;把所有十六进制转储拼起来再搜索:“SECRET-TOKEN-123” 0 次,“hunter2” 0 次,“Authorization” 0 次
  [-Djavax.net.debug=ssl:handshake] 输出 4922 行,其中十六进制转储的字节 3931 个;直接搜索文本:“SECRET-TOKEN-123” 出现 0 次;把所有十六进制转储拼起来再搜索:“SECRET-TOKEN-123” 0 次,“hunter2” 0 次,“Authorization” 0 次
  [-Djavax.net.debug=ssl:record] 输出 4922 行,其中十六进制转储的字节 3931 个;直接搜索文本:“SECRET-TOKEN-123” 出现 0 次;把所有十六进制转储拼起来再搜索:“SECRET-TOKEN-123” 0 次,“hunter2” 0 次,“Authorization” 0 次
  [-Djavax.net.debug=ssl:record:plaintext] 输出 5387 行,其中十六进制转储的字节 10926 个;直接搜索文本:“SECRET-TOKEN-123” 出现 0 次;把所有十六进制转储拼起来再搜索:“SECRET-TOKEN-123” 1 次,“hunter2” 1 次,“Authorization” 1 次
  [-Djavax.net.debug=all] 输出 5937 行,其中十六进制转储的字节 18718 个;直接搜索文本:“SECRET-TOKEN-123” 出现 0 次;把所有十六进制转储拼起来再搜索:“SECRET-TOKEN-123” 1 次,“hunter2” 1 次,“Authorization” 1 次

字符串与异常

实测 · 本机执行输出
  URL.toString():https://alice:S3cret!pw@example.com/path?x=1
  URI.toString():https://alice:S3cret!pw@example.com/path?x=1
  HttpRequest.toString():https://alice:S3cret!pw@example.com/path?x=1 GET
  URL.getAuthority() = alice:S3cret!pw@example.com,getUserInfo() = alice:S3cret!pw,getHost() = example.com(只有 getHost() 不含密码)
  Properties.toString():{db.user=alice, db.password=S3cret!pw}
  System.out.println(char[]) 打印:S3cret!pw
  println(Object) 打印 char[]:[C@7cca494b(只是类型与哈希);Arrays.toString(char[]):[S, 3, c, r, e, t, !, p, w];String.valueOf(char[]):S3cret!pw
  sun.java.command 系统属性(包含启动参数):Main leaks --password=cli-secret-42
  ProcessHandle.current().info().commandLine():java -cp out Main leaks --password=cli-secret-42
  Integer.parseInt(口令) 抛出:NumberFormatException: For input string: "S3cret!pw"  → 包含口令 = true
  new BigDecimal(口令) 抛出:NumberFormatException: Character S is neither a decimal digit number, decimal point, nor "e" notation exponential mark.  → 包含口令 = false
  UUID.fromString(口令) 抛出:IllegalArgumentException: Invalid UUID string: S3cret!pw  → 包含口令 = true
  URI.create("http://a b/" + 口令) 抛出:IllegalArgumentException: Illegal character in authority at index 8: http://a b/S3cret!pw  → 包含口令 = true
  Base64 解码含非法字符的口令 抛出:IllegalArgumentException: Illegal base64 character 21  → 包含口令 = false
  LocalDate.parse(口令) 抛出:DateTimeParseException: Text 'S3cret!pw' could not be parsed at index 0  → 包含口令 = true
  Enum.valueOf(口令) 抛出:IllegalArgumentException: No enum constant java.lang.Thread.State.S3cret!pw  → 包含口令 = true
  Cipher.getInstance 传入口令:NoSuchAlgorithmException: Cannot find any provider supporting AES/S3cret!pw/NoPadding  → 包含口令 = true
  另一个进程(同一用户)用 ps -o args= 看到的命令行:sh -c sleep 20; echo "$1" sh --token=ARGV-SECRET-777;“ARGV-SECRET-777” 出现 = True;在 ps eww 的输出里 “ENV-SECRET-888”(环境变量)出现 = False

哈希链

实测 · 本机执行输出
  N = 1000 条审计记录,每条 = JSON 正文 | 上一条的哈希 | 本条哈希。示例第 1 条:{"seq":1,"actor":"user1","action":"read","resource":"/orders/1","outcome":"ok"}|00000000000000000000…
  原始日志校验:全部通过
  篡改第 500 条的正文(把 delete 改成 read,ok 改成 denied):校验失败的第一条 seq = 500
  删除第 500 条:校验失败的第一条 seq = 500(原来第 501 条的上一条哈希对不上了)
  交换第 100 与第 101 条:校验失败的第一条 seq = 100
  删除最后 10 条(截断尾部):无锚点时校验 全部通过(没有发现);用外部保存的最后一条哈希作锚点时校验失败的位置 = 991(= 剩余条数 + 1)
  攻击者从第 500 条起把后面的哈希全部重新计算(普通 SHA-256 链,不需要任何密钥):无锚点校验 全部通过(篡改成功);用外部锚点(原始的最后一条哈希)校验失败的位置 = 1001
  同样的攻击,但日志用 HMAC-SHA256 链(密钥不在攻击者手里):攻击者用普通 SHA-256 重算后,用 HMAC 校验失败的第一条 seq = 500;原始 HMAC 日志校验 全部通过
  HMAC 链:生成 1000 条约 9.2 ms(每条约 9.2 µs),校验 1000 条约 8.2 ms

JFR 事件

实测 · 本机执行输出
  事件 jdk.TLSHandshake:1 个
  事件 jdk.X509Validation:2 个
  事件 jdk.X509Certificate:224 个
  事件 jdk.SecurityPropertyModification:0 个
  jdk.TLSHandshake 的内容:peerHost = "localhost" | peerPort = 9771 | protocolVersion = "TLSv1.3" | cipherSuite = "TLS_AES_256_GCM_SHA384" | certificateId = 1371337912
  jdk.X509Validation 的内容:startTime = 19:01:43.772 (2026-10-05) | certificateId = 4248717943 | certificatePosition = 1 | validationCounter = 1 | startTime = 19:01:43.772 (2026-10-05) | certificateId = 1371337912 | certificatePosition = 2 | validationCounter = 1
  jdk.X509Certificate 的内容(第一个事件):algorithm = "SHA256withECDSA" | subject = "CN=Local Test CA" | issuer = "CN=Local Test CA" | keyType = "EC" | keyLength = 256 | validFrom = 19:01:35.000 (2026-10-05) | validUntil = 19:01:35.000 (2026-10-07)
  整个 JFR 文件打印出的文本里 “SECRET-TOKEN-123” 出现 0 次,“hunter2” 出现 0 次

分步演示

安全审计日志
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / OpenJDK(Temurin 17.0.20、Homebrew 21.0.12 与 27,每篇注明实际用的版本);用 javac 编译、java 直接运行,需要第三方库时用 Maven 解析依赖;计时用 System.nanoTime,数字每次运行会有小幅抖动。JDK 21.0.12;OpenSSL 4.0.3 s_server 作 TLS 服务端;本机 macOS(ps 的环境变量可见性因系统版本而异);哈希链是自写的 1000 条示例,密钥是示例字符串
    • 没有验证:Log4j2 / Logback 的脱敏(%replace、Marker)、Java 的 System.Logger 与 JUL 过滤器、syslog / SIEM 转发、不可变存储(WORM)、时间戳服务(RFC 3161)、JFR 里 SecurityPropertyModification 与 SecurityProviderService 事件被触发的场景、jdk.TLSHandshake 在多连接下的数量
    京ICP备2026067502号-1京公网安备11010502063487号