In a previous post, I described the firmware encryption for some TP-Link Smart Switches. At the end of the post, I alluded to the fact that TP-Link does sign and verify firmware signatures, but did not dig deeper at the time.

Recently, I have investigated into how the signature verification works exactly.

From looking at some code in the tp-link-decrypt project on Github, and reverse engineering the firmware code responsible for verification, we indeed find that the final 128 bytes at the end of the firmware image is an RSA signature.

The signature is verified against an RSA public key that is hard-coded into the firmware as a base64 string:

BgIAAACkAABSU0ExAAQAAAEAAQBhVXK3wF79A6cXXFu0Y0wKz0dPQWi2dWPE7p8eY9e6PAqc5BBT
QPxi2/N1OotrUN11Q6cBXA0gmflRusUiJtdARng43tSWz2pZueskCC5kH9/+1/ACi2ZY1WlK5TVu5B
h0YCzAfvlmsbuPjk/W4S3Jco+ISDOrpF5wwuxlCHI2vQ==

The key is encoded in Microsoft’s PUBLICKEYBLOB format. openssl supports decoding such keys:

 % echo 'BgIAAACkAABSU0ExAAQAAAEAAQBhVXK3wF79A6cXXFu0Y0wKz0dPQWi2dWPE7p8eY9e6PAqc5BBT
QPxi2/N1OotrUN11Q6cBXA0gmflRusUiJtdARng43tSWz2pZueskCC5kH9/+1/ACi2ZY1WlK5TVu5B
h0YCzAfvlmsbuPjk/W4S3Jco+ISDOrpF5wwuxlCHI2vQ==' |\
  base64 -d |\
  openssl rsa -pubin -text
Public-Key: (1024 bit)
Modulus:
  00:bd:36:72:08:65:ec:c2:70:5e:a4:ab:33:48:88:
  8f:72:c9:2d:e1:d6:4f:8e:8f:bb:b1:66:f9:7e:c0:
  2c:60:74:18:e4:6e:35:e5:4a:69:d5:58:66:8b:02:
  f0:d7:fe:df:1f:64:2e:08:24:eb:b9:59:6a:cf:96:
  d4:de:38:78:46:40:d7:26:22:c5:ba:51:f9:99:20:
  0d:5c:01:a7:43:75:dd:50:6b:8b:3a:75:f3:db:62:
  fc:40:53:10:e4:9c:0a:3c:ba:d7:63:1e:9f:ee:c4:
  63:75:b6:68:41:4f:47:cf:0a:4c:63:b4:5b:5c:17:
  a7:03:fd:5e:c0:b7:72:55:61
Exponent: 65537 (0x10001)
writing RSA key
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC9NnIIZezCcF6kqzNIiI9yyS3h
1k+Oj7uxZvl+wCxgdBjkbjXlSmnVWGaLAvDX/t8fZC4IJOu5WWrPltTeOHhGQNcm
IsW6UfmZIA1cAadDdd1Qa4s6dfPbYvxAUxDknAo8utdjHp/uxGN1tmhBT0fPCkxj
tFtcF6cD/V7At3JVYQIDAQAB
-----END PUBLIC KEY-----

The code at the tp-link-decrypt seems to suggest a bespoke signature encoding scheme. However, upon closer inspection, the algorithm described is simply the PKCS#1v1.5 encoding scheme, specifically the sha1WithRSAEncryption variant.

The data that is being signed is actually the MD5 hash of the (encrypted) firmware image bytes (without including the signature at the end). It’s not clear why TP-Link chose to sign a MD5 hash (which is then hashed again using SHA1 as part of the RSA signature algorithm), instead of just signing the firmware image itself.

Yet, even knowing the above, my first attempt at re-implementing the signature verification routine in Golang fails. Here’s a self-contained test case to demonstrate:

func TestVerifySignature(t *testing.T) {
  pub := &rsa.PublicKey{
    N: big.NewInt(0).SetBytes([]byte{
      0xbd, 0x36, 0x72, 0x08, 0x65, 0xec, 0xc2, 0x70, 0x5e, 0xa4, 0xab, 0x33, 0x48, 0x88, 0x8f, 0x72,
      0xc9, 0x2d, 0xe1, 0xd6, 0x4f, 0x8e, 0x8f, 0xbb, 0xb1, 0x66, 0xf9, 0x7e, 0xc0, 0x2c, 0x60, 0x74,
      0x18, 0xe4, 0x6e, 0x35, 0xe5, 0x4a, 0x69, 0xd5, 0x58, 0x66, 0x8b, 0x02, 0xf0, 0xd7, 0xfe, 0xdf,
      0x1f, 0x64, 0x2e, 0x08, 0x24, 0xeb, 0xb9, 0x59, 0x6a, 0xcf, 0x96, 0xd4, 0xde, 0x38, 0x78, 0x46,
      0x40, 0xd7, 0x26, 0x22, 0xc5, 0xba, 0x51, 0xf9, 0x99, 0x20, 0x0d, 0x5c, 0x01, 0xa7, 0x43, 0x75,
      0xdd, 0x50, 0x6b, 0x8b, 0x3a, 0x75, 0xf3, 0xdb, 0x62, 0xfc, 0x40, 0x53, 0x10, 0xe4, 0x9c, 0x0a,
      0x3c, 0xba, 0xd7, 0x63, 0x1e, 0x9f, 0xee, 0xc4, 0x63, 0x75, 0xb6, 0x68, 0x41, 0x4f, 0x47, 0xcf,
      0x0a, 0x4c, 0x63, 0xb4, 0x5b, 0x5c, 0x17, 0xa7, 0x03, 0xfd, 0x5e, 0xc0, 0xb7, 0x72, 0x55, 0x61,
    }),
    E: 0x10001,
  }
  md5sum := []byte{0x6e, 0x47, 0xb8, 0x83, 0x4c, 0x9c, 0x0b, 0x61, 0x51, 0x1f, 0x13, 0x0d, 0x88, 0x8f, 0xd3, 0x3f}
  sig := []byte{
    0xf1, 0x29, 0x1d, 0xae, 0x71, 0xf0, 0xd3, 0x47, 0x3b, 0x4b, 0x26, 0xf4, 0x4f, 0x0c, 0x75, 0xc1,
    0x19, 0xa1, 0x8e, 0x04, 0x1b, 0x64, 0xe1, 0x2f, 0x50, 0xe2, 0x5a, 0x6e, 0x58, 0xda, 0x12, 0x42,
    0x91, 0x0f, 0x33, 0xa1, 0xa8, 0x4d, 0xd7, 0xd4, 0x2e, 0xac, 0xac, 0x92, 0xf4, 0x1f, 0x39, 0x8e,
    0xc3, 0x3c, 0xb7, 0xe0, 0x2a, 0x0d, 0x84, 0xed, 0xd0, 0xbb, 0x7a, 0x32, 0xed, 0xb5, 0xf8, 0x33,
    0x9a, 0xdd, 0x9b, 0x0e, 0x66, 0x1a, 0x90, 0xd2, 0xf4, 0xbb, 0x83, 0x72, 0x7e, 0x71, 0xc5, 0x1a,
    0xcf, 0x1e, 0x0a, 0x44, 0x56, 0x54, 0x33, 0xbd, 0xe8, 0x1c, 0xce, 0x61, 0x74, 0xbb, 0x08, 0xd6,
    0xb0, 0x7a, 0xda, 0x8d, 0x89, 0x60, 0xa7, 0x14, 0xb0, 0xb2, 0x30, 0xf0, 0xc4, 0xd5, 0x5c, 0x80,
    0xca, 0x10, 0xc5, 0x29, 0x7f, 0x83, 0x6a, 0xfc, 0x74, 0x34, 0xb1, 0x10, 0xc2, 0xd6, 0x08, 0x30,
  }

  hash := sha1.Sum(md5sum[:])
  if err := rsa.VerifyPKCS1v15(pub, crypto.SHA1, hash[:], sig); err != nil {
    t.Errorf("VerifyPKCS1v15(...): err = %v, want nil", err)
  }
}
VerifyPKCS1v15(...): err = crypto/rsa: verification error, want nil

It turns out, for whatever reason, the firmware (and the code in the tp-link-decrypt project) reverses the bytes of the signature before attempting verification. It’s unclear to me whether this is a normal part of validating RSA signatures, but it does seem odd to me. My guess is that the signature was generated by software that uses little-endian representation of integers, whereas many other RSA libraries use big-endian representations. This is consistent with the fact that Microsoft’s PUBLICKEYBLOB stores the modulus in little-endian.

Adding a slices.Reverse(sig) call to the above code makes the signature verification pass without issues.

Summary

To perform signature verification on the firmware image:

  1. Split the firmare image into the data (the first total_length - 128 bytes) and signature (the last 128 bytes).
  2. Calculate the MD5 hash of the data.
  3. Reverse the ordering of the bytes of the signature.
  4. Use the PKCS#1v1.5 sha1WithRSAEncryption signature algorithm to verify the signature against the MD5 hash of the data.