利用证书透明度日志构建隐蔽通信信道

admin 2026-03-03 09:50:26 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文介绍了一种利用证书透明度日志构建隐蔽通信信道的技术方案。核心原理是通过精心构造RSA公钥模数嵌入隐藏消息,利用Let’sEncrypt签发证书并写入公开的CT日志中。接收者无需直接连接发送者服务器,仅通过查询CT日志API即可提取数据,实现了通信双方的去关联性。文章提供了完整的Python代码实现与OpenBSD部署指南,验证了利用CT日志不可篡改特性进行隐蔽数据传输的可行性,为隐蔽通信提供了新的实战思路。 综合评分: 95 文章分类: 红队,实战经验,安全开发,应用安全


cover_image

利用证书透明度日志构建隐蔽通信信道

latedeployment latedeployment

securitainment

2026年2月14日 15:50 中国香港

| 原文链接 | 作者 | | — | — | | https://latedeployment.github.io/posts/certificate-transparency-as-communication-channel/ | latedeployment |

本文是 Certificate Transparency 系列的第三部分。

引言

本文描述了一种利用证书验证基础设施,通过 Certificate Transparency 日志分发消息的方法。

读取者无需回连发送者的域名,并且数据也永远不会被 _删除_。

TL;DR 快速概览

  • 购买一个域名,例如 example.com
  • 租用一台便宜的 VPS,将域名的 DNS 指向该服务器
  • 生成包含隐藏数据的证书
  • 使用 Let's Encrypt签署证书,数据将被存储到 Certificate Transparency 日志中
  • 读取者查找已知域名的证书并读取消息
  • 读取者只与 Certificate Transparency 日志 API 端点的域名通信

背景

参见第一部分 Certificate Transparency 101 了解更多关于 Certificate Transparency 的信息。

Certificate Transparency 日志是公开可访问的、仅追加的 Merkle 哈希树,记录着由证书颁发机构签发的证书。

它们的行为类似于区块链,因此数据永远无法从中删除。

这些日志可用于:

  • 检测证书滥用

  • 提供可追溯性

    (追踪谁签发了哪张证书)

  • 允许浏览器验证

    你所访问域名的证书在被信任之前已被记录

每个证书颁发机构都有自己的日志,像 crt.sh这样的工具允许搜索这些日志,但你也可以自行与 API 通信。日志的 API 在 RFC 6962中有定义,每个 CA 都有自己的 API 端点用于查询。

因此,我们拥有了一种可以追加数据的日志——前提是我们拥有一个域名并能为其创建证书。

如果读取者通过 API 读取证书,它完全不需要与我们的域名通信,而是通过 CA 的域名来读取数据。

更有趣的是,我们可以在证书本身中嵌入一些数据,方法包括利用 X.509 扩展或对主体备用名称 (SAN) 的某些用法,但这里我选择了公钥本身。

隐藏数据

参见 How to Hide Encrypted Data Inside RSA Public Keys 了解更多信息。

基本原理是,如果我们搜索素数足够 长的时间_,就能找到这样的素数——当它们相乘 (构成 RSA 模数) 时,特定值会出现在模数的某些比特位上。换言之,通过精心选择素数,我们可以将消息嵌入_ 到模数本身中。

在这个具体演示中,我使用了模数的低位比特,但实际上我们可以做得更巧妙,比如跳过某些比特位——例如每第 10 个比特是一个隐藏比特,诸如此类。这并不是最重要的。

这里的搜索速度相当快,比预期的还要快 (不到一分钟),而且我相信更厉害的人能想出更好的方法来隐藏实际数据。在实践中,隐藏的数据本身可以被加密,因此看起来就像随机的 (“普通的”) 比特位,读取者只需要知道在哪里寻找。

创建好素数后,我们就可以用它们生成证书并将其追加到 Certificate Transparency 日志中。

我使用了 Let’s Encrypt 配合 OpenBSD 的 acme-client,但我相信也可以用其他方式完成。Let’s Encrypt 通过一些 HTTP 请求验证了我的域名,最终批准了我的证书。

换句话说,我们通过向 Let’s Encrypt 提供证书来将其”上传”到 Certificate Transparency 日志中,Let’s Encrypt 批准后,包含我们嵌入数据的证书就被 永久存储在日志中了。

在 crt.sh上浏览并搜索我的域名,几分钟后就显示了该证书,但直接查询 API 则显示得更快。

最终我们的证书看起来是这样的:

Subject Public Key Info:
        Public Key Algorithm: rsaEncryption
            RSA Public-Key: (2048 bit)
            Modulus:
                ============================================
                ============== UNIMPORTANT DATA ============
                ============================================
                5b:48:65:6c:6c:6f:00:00:00:00:00:00:00:00:00:
                00:01

下面的数据中包含 ASCII 编码的 Hello,即 _48:65:6c:6c:6f_。

整个流程在下方附带的 Python 代码中有描述,具体请查看 generate_rsa_key_with_hidden_data函数。

通过 crt.sh 读取

为了简化这个示例,我直接使用 crt.shcrt.sh网站解析所有相关的 Certificate Transparency 日志,并提供一个更简便的 “API” 来获取相关信息,尽管它既没有真正的 API,也并非总是可用 (有时需要刷新页面,因为它的数据库会宕机)。

domain="example.com"
cert_id=$(curl -s  "https://crt.sh/?q=${domain}&output=json" | jq -r '.[0].id')
cert=$(curl -s "https://crt.sh/?d=${cert_id}")
modulus=$(echo "$cert" | openssl x509 -noout -modulus | sed 's/Modulus=//')
message=$(echo "$modulus" | tr -d ':' | tail -c 33)
echo"$modulus"
echo"$message"

这样我们就能读取隐藏在证书中的消息了。

通过 Certificate Transparency API 读取

RFC 6962 (Certificate Transparency)提供了一套 API,允许我们高效地查询日志。如果想查找特定的证书,可以使用二分搜索按时间戳 (例如签发日期) 定位条目。这些日志可能非常庞大,有些包含超过 10 亿条记录,因此必须使用二分搜索。

这意味着,在最坏情况下,我们只需要大约 30 次 API 查询就能找到任意一张证书。

我们首先调用 get-sthAPI 获取树的大小 (即日志中有多少条目),然后使用 get-entries配合合理大小的 start和 end来获取证书。假设我们知道某张证书是在特定日期创建的,就可以检查这些证书是否接近我们的目标,然后跳转到其他位置继续查找,直到找到正确的证书。

提供 Certificate Transparency 日志端点的公司包括 Sectigo、DigiCert、Let’s Encrypt、Cloudflare、Google 等。3

从日志中读取数据后,我们需要解析它才能读取数据,以下是一个示例:

# Decode the leaf input (MerkleTreeLeaf structure from RFC 6962)
    leaf_input = base64.b64decode(entry["leaf_input"])

# MerkleTreeLeaf structure:
# - Byte 0: Version (0x00)
# - Byte 1: MerkleLeafType (0x00 for timestamped_entry)
# - Bytes 2-9: Timestamp (8 bytes, milliseconds since Unix epoch)
# - Bytes 10-11: LogEntryType (0x0000 for x509_entry,
#                               0x0001 for precert_entry)

    timestamp_ms =int.from_bytes(leaf_input[2:10], byteorder='big')
    timestamp = datetime.fromtimestamp(timestamp_ms /1000.0)

# Get entry type to determine how to parse
    entry_type =int.from_bytes(leaf_input[10:12], byteorder='big')

# Decode extra_data
    extra_data = base64.b64decode(entry["extra_data"])
    cert_data =None

if entry_type ==0:  # x509_entry
# For x509_entry: leaf_input has the certificate after header
# Bytes 12-14: certificate length (3 bytes)
# Bytes 15+: certificate DER
        cert_len =int.from_bytes(leaf_input[12:15], byteorder='big')
        cert_data = leaf_input[15:15+ cert_len]

elif entry_type ==1:  # precert_entry
# For precert_entry:
# - leaf_input: header + issuer_key_hash(32) + tbs_cert
# - extra_data: pre_certificate + chain
#
# extra_data format:
# - 3 bytes: length of pre_certificate
# - N bytes: pre_certificate (full DER cert with poison ext)
# - 3 bytes: length of chain
# - M bytes: chain
        cert_len =int.from_bytes(extra_data[0:3], byteorder='big')
        cert_data = extra_data[3:3+ cert_len]
else:
raiseValueError(f"Unknown entry type: {entry_type}")

# Parse the certificate
    cert = x509.load_der_x509_certificate(cert_data, default_backend())

使用场景

由于读取数据的连接目标是”正常”的域名 (如 Cloudflare 或 Sectigo),这使得阻止数据读取过程变得更加困难。我们存储的数据也永远不会被删除。

虽然 Let’s Encrypt 对证书签发有速率限制,但我相信人们会找到方法来克服这一点。

要构建一条大消息,只需创建多张证书,将它们堆叠起来记录到日志中,从而突破公钥模数的大小限制。另一种方法是创建子域名,为额外的证书提供”存储空间”。

代码

证书生成

#!/usr/bin/env python3
import secrets

from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.backends import default_backend
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives.asymmetric.rsa import (
    RSAPrivateNumbers, RSAPublicNumbers
)

defmiller_rabin(n, k=10):
"""Miller-Rabin primality test."""
if&nbsp;n&nbsp;<2:
returnFalse
if&nbsp;n&nbsp;==2or&nbsp;n&nbsp;==3:
returnTrue
if&nbsp;n&nbsp;%2==0:
returnFalse

&nbsp; &nbsp; r, d&nbsp;=0, n&nbsp;-1
while&nbsp;d&nbsp;%2==0:
&nbsp; &nbsp; &nbsp; &nbsp; r&nbsp;+=1
&nbsp; &nbsp; &nbsp; &nbsp; d&nbsp;//=2

for_inrange(k):
&nbsp; &nbsp; &nbsp; &nbsp; a&nbsp;=&nbsp;secrets.randbelow(n&nbsp;-3)&nbsp;+2
&nbsp; &nbsp; &nbsp; &nbsp; x&nbsp;=pow(a, d, n)
if&nbsp;x&nbsp;==1or&nbsp;x&nbsp;==&nbsp;n&nbsp;-1:
continue
for_inrange(r&nbsp;-1):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; x&nbsp;=pow(x,&nbsp;2, n)
if&nbsp;x&nbsp;==&nbsp;n&nbsp;-1:
break
else:
returnFalse
returnTrue

defgenerate_prime(bit_size):
"""Generate a random prime of given bit size."""
whileTrue:
&nbsp; &nbsp; &nbsp; &nbsp; candidate&nbsp;=&nbsp;secrets.randbits(bit_size&nbsp;-1)
&nbsp; &nbsp; &nbsp; &nbsp; candidate&nbsp;|=&nbsp;(1<<&nbsp;(bit_size&nbsp;-1))&nbsp;|1# Set MSB and LSB
if&nbsp;miller_rabin(candidate,&nbsp;20):
return&nbsp;candidate

defgenerate_rsa_key_with_hidden_data(message,&nbsp;key_size=2048,
data_bits=128):
"""
&nbsp; &nbsp; Generate RSA key with hidden data in the modulus.

&nbsp; &nbsp; The trick: we want (p * q) mod 2^data_bits = target
&nbsp; &nbsp; So we pick q, then find p where:
&nbsp; &nbsp; &nbsp; &nbsp; p mod 2^data_bits = target * q^(-1) mod 2^data_bits
&nbsp; &nbsp; """
&nbsp; &nbsp; prime_bits&nbsp;=&nbsp;key_size&nbsp;//2
&nbsp; &nbsp; data_bytes&nbsp;=&nbsp;(data_bits&nbsp;+7)&nbsp;//8

# Pad message and convert to int
&nbsp; &nbsp; msg_padded&nbsp;=&nbsp;message.ljust(data_bytes,&nbsp;b'\x00')
&nbsp; &nbsp; target&nbsp;=int.from_bytes(msg_padded,&nbsp;'big')&nbsp;|1# Must be odd

&nbsp; &nbsp; mask&nbsp;=&nbsp;(1<<&nbsp;data_bits)&nbsp;-1

# Generate fixed prime q
&nbsp; &nbsp; q&nbsp;=&nbsp;generate_prime(prime_bits)

# Calculate required lower bits for p
&nbsp; &nbsp; q_inv_mod&nbsp;=pow(q,&nbsp;-1,&nbsp;1<<&nbsp;data_bits)
&nbsp; &nbsp; p_lower&nbsp;=&nbsp;(target&nbsp;*&nbsp;q_inv_mod)&nbsp;&&nbsp;mask

# Find prime p with those lower bits
&nbsp; &nbsp; upper_bits&nbsp;=&nbsp;prime_bits&nbsp;-&nbsp;data_bits
for_inrange(100000):
&nbsp; &nbsp; &nbsp; &nbsp; upper&nbsp;=&nbsp;secrets.randbits(upper_bits&nbsp;-1)
&nbsp; &nbsp; &nbsp; &nbsp; upper&nbsp;|=&nbsp;(1<<&nbsp;(upper_bits&nbsp;-1))
&nbsp; &nbsp; &nbsp; &nbsp; p_candidate&nbsp;=&nbsp;(upper&nbsp;<<&nbsp;data_bits)&nbsp;|&nbsp;p_lower

if&nbsp;p_candidate.bit_length()&nbsp;!=&nbsp;prime_bits:
continue
if&nbsp;miller_rabin(p_candidate,&nbsp;20):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; p&nbsp;=&nbsp;p_candidate
break
else:
raiseValueError("Could not find suitable prime")

if&nbsp;p&nbsp;<&nbsp;q:
&nbsp; &nbsp; &nbsp; &nbsp; p, q&nbsp;=&nbsp;q, p

&nbsp; &nbsp; n&nbsp;=&nbsp;p&nbsp;*&nbsp;q
&nbsp; &nbsp; e&nbsp;=65537
&nbsp; &nbsp; phi_n&nbsp;=&nbsp;(p&nbsp;-1)&nbsp;*&nbsp;(q&nbsp;-1)
&nbsp; &nbsp; d&nbsp;=pow(e,&nbsp;-1, phi_n)
&nbsp; &nbsp; dp&nbsp;=&nbsp;d&nbsp;%&nbsp;(p&nbsp;-1)
&nbsp; &nbsp; dq&nbsp;=&nbsp;d&nbsp;%&nbsp;(q&nbsp;-1)
&nbsp; &nbsp; qinv&nbsp;=pow(q,&nbsp;-1, p)

&nbsp; &nbsp; pub&nbsp;=&nbsp;RSAPublicNumbers(e, n)
&nbsp; &nbsp; priv&nbsp;=&nbsp;RSAPrivateNumbers(p, q, d, dp, dq, qinv, pub)
return&nbsp;priv.private_key(default_backend())

defextract_from_modulus(n,&nbsp;data_bits=128):
"""Extract hidden data from modulus. No private key needed"""
&nbsp; &nbsp; mask&nbsp;=&nbsp;(1<<&nbsp;data_bits)&nbsp;-1
&nbsp; &nbsp; data_int&nbsp;=&nbsp;n&nbsp;&&nbsp;mask
return&nbsp;data_int.to_bytes((data_bits&nbsp;+7)&nbsp;//8,&nbsp;'big')

defcreate_certificate(private_key,&nbsp;domain):
"""Create a self-signed certificate."""
&nbsp; &nbsp; subject&nbsp;=&nbsp;x509.Name([
&nbsp; &nbsp; &nbsp; &nbsp; x509.NameAttribute(NameOID.COUNTRY_NAME,&nbsp;"CH"),
&nbsp; &nbsp; &nbsp; &nbsp; x509.NameAttribute(NameOID.COMMON_NAME, domain),
&nbsp; &nbsp; ])

&nbsp; &nbsp; now&nbsp;=&nbsp;datetime.now(timezone.utc)
&nbsp; &nbsp; cert&nbsp;=&nbsp;(
&nbsp; &nbsp; &nbsp; &nbsp; x509.CertificateBuilder()
&nbsp; &nbsp; &nbsp; &nbsp; .subject_name(subject)
&nbsp; &nbsp; &nbsp; &nbsp; .issuer_name(subject)
&nbsp; &nbsp; &nbsp; &nbsp; .public_key(private_key.public_key())
&nbsp; &nbsp; &nbsp; &nbsp; .serial_number(x509.random_serial_number())
&nbsp; &nbsp; &nbsp; &nbsp; .not_valid_before(now)
&nbsp; &nbsp; &nbsp; &nbsp; .not_valid_after(now&nbsp;+&nbsp;timedelta(days=90))
&nbsp; &nbsp; &nbsp; &nbsp; .add_extension(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; x509.SubjectAlternativeName([x509.DNSName(domain)]),
critical=False
&nbsp; &nbsp; &nbsp; &nbsp; )
&nbsp; &nbsp; &nbsp; &nbsp; .sign(private_key, hashes.SHA256())
&nbsp; &nbsp; )
return&nbsp;cert

# === Main ===
message&nbsp;=b"Hello"
domain&nbsp;="example.com"

# Generate key with hidden data
private_key&nbsp;=&nbsp;generate_rsa_key_with_hidden_data(message)

# Create certificate
cert&nbsp;=&nbsp;create_certificate(private_key, domain)

# Save certificate
withopen("cert.pem",&nbsp;"wb")&nbsp;as&nbsp;f:
&nbsp; &nbsp; f.write(cert.public_bytes(serialization.Encoding.PEM))

# Verify: extract hidden data from public key
n&nbsp;=&nbsp;private_key.public_key().public_numbers().n
extracted&nbsp;=&nbsp;extract_from_modulus(n)
print(f"Hidden message:&nbsp;{extracted}") &nbsp;# b'Hello\x00...\x01'

部署

以下展示如何将证书部署到 OpenBSD 服务器并运行 acme-client 来获取 Let’s Encrypt 证书。

在本地生成包含隐藏数据的密钥

# we call the tool from above
$ python cert_generator.py -d example.com -m&nbsp;"secret"&nbsp;-t pubkey

# This creates:
# &nbsp; &nbsp;- output/example_com.key &nbsp;(private key with hidden data in modulus)

将密钥部署到 OpenBSD 服务器

$ DOMAIN="example.com"
$ SERVER="[email protected]"

# Copy the key with hidden data
$ scp output/example_com.key&nbsp;\
${SERVER}:/etc/ssl/private/${DOMAIN}.key
$ ssh&nbsp;${SERVER}"chmod 600 /etc/ssl/private/${DOMAIN}.key"

在服务器上配置 acme-client

# SSH into the server and create /etc/acme-client.conf:

authority letsencrypt {
&nbsp; &nbsp; api url "https://acme-v02.api.letsencrypt.org/directory"
&nbsp; &nbsp; account key "/etc/acme/letsencrypt-privkey.pem"
}

domain example.com {
&nbsp; &nbsp; domain key "/etc/ssl/private/example.com.key"
&nbsp; &nbsp; domain certificate "/etc/ssl/example.com.crt"
&nbsp; &nbsp; domain full chain certificate "/etc/ssl/example.com.pem"
&nbsp; &nbsp; sign with letsencrypt
}

运行 acme-client 获取证书

ssh&nbsp;${SERVER}"acme-client -v&nbsp;${DOMAIN}"

获取并验证证书

scp&nbsp;${SERVER}:/etc/ssl/${DOMAIN}.crt ./output/
openssl x509 -in output/${DOMAIN}.crt -noout -modulus

到此我们就完成了,数据已经上传到 Certificate Transparency 日志中。

参考文献

1 RFC 6962 – Certificate Transparency

2 crt.sh – Certificate Search

[3] https://certificate.transparency.dev/logs/


免责声明:本博客文章仅用于教育和研究目的。提供的所有技术和代码示例旨在帮助防御者理解攻击手法并提高安全态势。请勿使用此信息访问或干扰您不拥有或没有明确测试权限的系统。未经授权的使用可能违反法律和道德准则。作者对因应用所讨论概念而导致的任何误用或损害不承担任何责任。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:securitainment latedeployment latedeployment《利用证书透明度日志构建隐蔽通信信道》

评论:0   参与:  0