Reverse-engineering Lyric file format – handling encryption with a known plaintext attack

This article explains how to reverse-engineer file formats by using what is called a known plaintext attack. This is a kind of attack when an attacker has an ability to pass the plaintext to the oracle (in our case the enryption algorithm) and receive back the encrypted text, and do it as many times as needed.

Yesterday I was working on adding support for reading Encore! Lyric files in the Karaoke player. I do not have this player anymore (I had purchased the lifetime license from Igor Marinin, but its author revoked it after I reached out to him asking whether the music he bundled with the player is properly licensed, as it appeared to be pirated – do not deal with this guy). However my friend sent me a few files to look at, which appear encrypted. So I asked him to create a file with a single lyric line of 96 ‘@’ characters.

The ‘@’ was chosen because its binary representation of 01000000 makes it the easiest to detect the XOR-based encryption patterns, while it is a printable ASCII character which is easy to type and has a known encoding. Also the string contains the same characters so if it is XORed agains a fixed size key, we would see the repeated pattern. I have asked him also to create another lyric with multiple lines of ‘A’ characters, to test my theories.

The result I got back looked as following:

c7 e0 ec 1b f1 92 a8 60 aa a0 b0 a0 ae aa a5 60 ad a0 60 aa ae ac af bc be b2 a5 b0 a5 61 87 a0 af b3 b1 b2 a8 60 aa a0 b0 a0 ae aa a5 60 ad a0 60 aa ae ac af bc be b2 a5 b0 a5 61 87 a0 af b3 b1 b2 a8 60 aa a0 b0 a0 ae aa a5 60 ad a0 60 aa ae ac af bc be b2 a5 b0 a5 61 87 a0 af b3 b1 b2 a8 60 aa a0 b0 a0

Studying it for a few minutes you can see the repeating pattern:

c7 e0 ec 1b f1 92
a8 60 aa a0 b0 a0 ae aa a5 60 ad a0 60 aa ae ac af bc be b2 a5 b0 a5 61 87 a0 af b3 b1 b2
a8 60 aa a0 b0 a0 ae aa a5 60 ad a0 60 aa ae ac af bc be b2 a5 b0 a5 61 87 a0 af b3 b1 b2

and so on. So we can see that the first six bytes encode something else (timing, most likely), while the rest encode the lyric text. We can also see that the lenght of the encryption key is 30 bytes, as the encrypted pattern repeats every 30 bytes. The second lyric file with ‘A’ also showed repeated pattern after offset 6, and the pattern repeated on each line, which further confirms the theory.

Now let’s assume the encryption is performed using XOR (and not add or moddiv, for example), and try to find the key for the repeated pattern by XORing it with the known plaintext:

a8 60 aa a0 b0 a0 ae aa a5 60 ad a0 60 aa ae ac af bc be b2 a5 b0 a5 61 87 a0 af b3 b1 b2
=  =  =  =  =  =  =  =  =  =  =  =  =  =  =  =  =  =  =  =  =  =  =  =  =  =  =  =  =  =
e8 20 ea e0 f0 e0 ee ea e5 20 ed e0 20 ea ee ec ef fc fe f2 e5 f0 e5 21 c7 e0 ef f3 f1 f2

and since we applied the key from the offset of 6th, this means it should be “moved” 6 bytes back, and would look the following:

c7 e0 ef f3 f1 f2 e8 20 ea e0 f0 e0 ee ea e5 20 ed e0 20 ea ee ec ef fc fe f2 e5 f0 e5 21

Now, applying it to the original encoded content, we decode our line as following:

00 00 03 e8 00 60 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 40 ...

And now we can decypher the fields:

  • First 4 bytes are lyric timing in big-endian order. Since my friend created this line at the 20 second mark, and 0x03E8 represents 1000, the value should be multiplied by 20.
  • Next two bytes are the lenght of the following text, again in big-endian order
  • Then the text follows in the codepage encoding (not unicode and not utf-8). This means you cannot create lyrics in Chinese and send it, for example, to a Vietnamese user. It also means you cannot mix languages in the lyrics if any characters in the language are not in the 7-bit ASCII set.

Overall it took less than an hour to decrypt and write a Python 3 decryptor for this format (change “windows-1251” to the encoding your lyrics are encoded if it is different):

import sys, struct
fh = open( sys.argv[1], "rb" )
data = fh.read()
fh.close()

# Decode
xorarray = bytes([ 0xC7, 0xE0, 0xEF, 0xF3, 0xF1, 0xF2, 0xE8, 0x20, 0xEA, 0xE0, 0xF0, 0xE0, 0xEE, 0xEA, 0xE5, 0x20, 0xED, 0xE0, 0x20, 0xEA, 0xEE, 0xEC, 0xEF, 0xFC, 0xFE, 0xF2, 0xE5, 0xF0, 0xE5, 0x21 ])
outdata = bytearray()

for o in range( 0, len(data) ):
    outdata.extend( bytes([ data[o] ^ xorarray [o % len(xorarray)] ]) )

o = 0
while o < len(outdata):
    timing = struct.unpack( ">I", outdata[o:o+4] )[0] * 20
    textlen = struct.unpack( ">H", outdata[o+4:o+6] )[0]
    strdata = bytes(outdata[o+6:o+textlen+6]).decode("windows-1251")
    o += textlen+6
    print (timing, strdata)

Lesson learned: if you encrypt against a static key using plain XOR, finding out the key would be a trivial task which will not require any analysis of your code.

 

This entry was posted in reverse engineering.

Comments are closed.