Skip to content

Instantly share code, notes, and snippets.

@georgeyjm
Created August 23, 2022 06:37
Show Gist options
  • Save georgeyjm/5aabd0671eec3a3973176b4e055ef528 to your computer and use it in GitHub Desktop.
Save georgeyjm/5aabd0671eec3a3973176b4e055ef528 to your computer and use it in GitHub Desktop.
Netease NCM Decryption with Metadata and Album Art Embedding
import os
import sys
import json
import base64
import struct
import binascii
from pathlib import Path
import argparse
from Crypto.Cipher import AES
from mutagen.id3 import ID3, TIT2, TPE1, TALB, APIC
from mutagen.flac import FLAC, Picture
AES_CORE_KEY = binascii.a2b_hex('687A4852416D736F356B496E62617857')
AES_META_KEY = binascii.a2b_hex('2331346C6A6B5F215C5D2630553C2728')
def unpad(s):
if isinstance(s[-1], int):
cutoff = s[-1]
else:
cutoff = ord(s[-1])
return s[:-cutoff]
def unpack(data):
return struct.unpack('<I', bytes(data))[0]
def aes_decrypt(key, data, unpad_data=True, mode=AES.MODE_ECB):
cryptor = AES.new(key, mode)
decrypted = cryptor.decrypt(data)
if unpad:
decrypted = unpad(decrypted)
return decrypted
def remove_prefix(target, prefix):
if target.startswith(prefix):
return target[len(prefix):]
return target
def read_next_data(fp, length=4, is_array=False, xor_byte=None):
data_length = unpack(fp.read(length))
data = fp.read(data_length)
if not is_array:
return data
data = bytearray(data)
if xor_byte is not None:
for i in range(len(data)):
data[i] ^= xor_byte
return data
def get_key_box(key_data):
key_box = bytearray(range(256))
key_length = len(key_data)
j = 0
for i in range(256):
j = (key_box[i] + j + key_data[i % key_length]) & 0xff
key_box[i], key_box[j] = key_box[j], key_box[i]
box_map = bytearray()
for i in range(256):
j = (i + 1) & 0xff
s = key_box[(j + key_box[j]) & 0xff]
box_map.append(key_box[(key_box[j] + s) & 0xff])
return box_map
def recover_data(fp, key_box):
raw_data = bytearray(fp.read())
for i in range(len(raw_data)):
raw_data[i] ^= key_box[i & 0xff]
return raw_data
def fill_metadata(file, metadata, album_art):
# Format artists
artists = [artist[0] for artist in metadata['artist']]
if len(artists) <= 2:
artists_string = ' & '.join(artists)
else:
artists_string = ', '.join(artists[:-1]) + ' & ' + artists[-1]
# Get album art format
if album_art:
album_art_format = metadata.get('albumPic', '').split('.')[-1]
album_art_format = 'jpeg' if album_art_format == 'jpg' else album_art_format
album_art_format = 'image/' + album_art_format
if metadata['format'].lower() == 'mp3':
audio = ID3(file)
audio.add(TPE1(encoding=3, text=artists_string)) # accepts list as input but for the sake of this project
audio.add(TIT2(encoding=3, text=metadata['musicName']))
audio.add(TALB(encoding=3, text=metadata['album']))
if album_art:
audio.add(APIC(encoding=3, type=3, mime=album_art_format, desc='Cover Art', data=album_art))
audio.save()
elif metadata['format'].lower() == 'flac':
audio = FLAC(file)
if audio.tags is None:
audio.add_tags()
audio['ARTIST'] = artists_string # accepts list but for the sake of this project
audio['TITLE'] = metadata['musicName']
audio['ALBUM'] = metadata['album']
if album_art:
picture = Picture()
picture.type = 3
picture.mime = album_art_format
picture.data = album_art
audio.add_picture(picture)
audio.save()
def recover_ncm(file):
with file.open('rb') as f:
### Get header data
header = f.read(8)
assert binascii.b2a_hex(header) == b'4354454e4644414d'
f.seek(2, 1)
### Get key data
key_data = read_next_data(f, is_array=True, xor_byte=0x64)
key_data = aes_decrypt(AES_CORE_KEY, bytes(key_data))
key_data = remove_prefix(key_data, b'neteasecloudmusic')
key_box = get_key_box(key_data)
### Get metadata
metadata = read_next_data(f, is_array=True, xor_byte=0x63)
metadata = remove_prefix(bytes(metadata), b'163 key(Don\'t modify):')
metadata = base64.b64decode(metadata)
metadata = aes_decrypt(AES_META_KEY, metadata)
metadata = remove_prefix(metadata, b'music:')
metadata = json.loads(metadata.decode('utf-8'))
# TODO: implement CRC32 error-checking
crc32 = unpack(f.read(4))
f.seek(5, 1)
### Get album image data
image_data = read_next_data(f)
### Create recovered file
recovered_data = recover_data(f, key_box)
recovered_file = file.with_suffix('.' + metadata['format'])
with recovered_file.open('wb') as recovered_f:
recovered_f.write(recovered_data)
fill_metadata(recovered_file, metadata, image_data)
print('Decrypted:', metadata['musicName'])
parser = argparse.ArgumentParser(description='Recover original files from proprietary NCM files.')
parser.add_argument('files', type=Path, nargs='*', help='NCM files')
args = parser.parse_args()
for i, file in enumerate(args.files):
print(f'({i+1}/{len(args.files)})', end=' ')
recover_ncm(file)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment