Skip to content

Instantly share code, notes, and snippets.

Created June 14, 2017 17:23
Show Gist options
  • Save anonymous/dfc8d8e4082886a3f88b221bd2dec648 to your computer and use it in GitHub Desktop.
Save anonymous/dfc8d8e4082886a3f88b221bd2dec648 to your computer and use it in GitHub Desktop.

Über 80.000 Bücher zum einfachen Download oder Warum md5(reverse(userid)) kein guter DRM-Key ist

2014 startete "DIE GRATIS EBOOK FLATRATE" (Eigenschreibweise) Readfy, die sich anschickte, das Spotify für Bücher zu werden. Das Modell ist simpel: über die Android- und iOS-App können eBooks kostenlos gelesen werden, dafür wird permanent Werbung angezeigt (1 Banner sowie Videos alle paar Seiten). (Inzwischen können Bücher auch für 0.99€-4.99€ gemietet und dann werbefrei und offline gelesen werden.) So gut es klingt, gibt es damit einige offensichtliche Probleme: Die App funktioniert nur online (nach 2min geht sonst das Buch zu), die Werbung nervt und die Bücher können nur mit einer App und nur auf bestimmten Geräten gelesen werden. Und weil das so ja nicht schön ist, habe ich mir gedacht, dass ich mir ja mal anschauen kann, wie die App funktioniert, und ob da nicht irgendwo ePubs rausfallen.

tl;dr

Mit dem Script in diesem Repo kannst du belibige Bücher von readfy herunterladen und dekodieren.

Nutzung

Benötigt werden:

Dependencies

Um die Dependencies zu installieren, einfach den verlinkten Anleitungen folgen. Alternativ, wenn pip installiert ist, folgendes ausführen:

pip install -r requirements.txt

Bücher laden

Zuerst muss ein Account in der readfy-App erstellt werden. Danach ist es am besten, die eigenen Zugangsdaten in der readfy.py einzutragen, sonst müssen sie bei jedem Ausführen der Daten angegeben werden. Danach einfach:

python readfy.py

Und den Anweisungen folgen.


Wie entstand das Script?

Das Procedere hier ist das ganz normale, das bei fast jeder Android-App zum Ziel führt.

Verwendete Tools

Vorgehen

Zuerst starte ich mitmproxy auf meinem Rechner und richte es als Proxy auf dem Android-Gerät ein. Danach öffne ich den Browser, lade mitm.it und installiere von dort die CA des Proxies, damit mein Smartphone den SSL-Zertifikaten des Proxies vertraut und ich alle Daten mitschneiden und decofieren kann. Nun öffne ich die App, melde mich an und probiere alle relevanten Funktionen einmal aus. Dabei sollte sich die Liste im mitmproxy-Fenster langsam füllen. Wenn das geschehen ist, schaue ich durch die Einträge und versuche den Ablauf zu verstehen. Im Falle von Readfy ist das sehr einfach, wie etwas weiter unten beschrieben.

Nun lade ich die .apk-Datei der App auf meinen Rechner und extrahiere daraus die Datei classes.dex (.apk-Dateien sind einfach Zip-Dateien und können mit den entsprechenden Programmen geöffnet werden). Dann wandle ich die Datei mit dex2jar (dex2jar classes.dex) in eine .jar-Datei um. Diese Datei kann ich jetzt mit JD-GUI öffnen und so eine ungefähre Sicht auf den Quellcode nehmen. Leider geht in dem Prozess einiges verloren bzw wird falsch interpretiert, aber es reicht, um einzelne Teile zu verstehen.

Weil einige Teile der App so nicht gelesen werden können, nutze ich außerdem Apktool und dekompiliere damit die App in smali-Code. Dieser ist zwar deutlich schwerer lesbar als Java-Code, kann aber wertvolle Hinweise liefern.

Funktionsweise der App

API-Calls

Alle API-Calls sind Requests an eine URL nach dem folgenden Schema:

https://api.readfy.com/api2/{METHOD}?session_token={TOKEN}

Dabei variiert die Methode von Aufruf zu Aufruf, wähend der Token sich nur beim Login ändern. Im Post-Body werden die Daten als JSON-Objekt gesendet.

Als Antwort kommt ebenfalls ein JSON-Objekt mit drei Feldern: success, error und response. Die ersten beiden geben an, ob die Anfrage erfolgreich war und wenn nicht, was der Fehler war. Response enthält die eigentlichen Antwort-Daten.

Hier eine kleine Auswahl an Requests:

Login

Zum Login werden einfach eMail und Passwort des Nutzers an den Server gesendet, dieser liefert das Nutzer-Profil mit einem Session-Token zurück. Für alle weiteren Requests wird dieser Token verwendet. Außerdem wird die User-ID übertragen.

HTTP: POST
METHOD: users/login
TOKEN: null
BODY: {"user":EMAIL,psw":PASSWORT}
RESPONSE: Nutzer-Profil sowie Session-Token

Genre-Liste

HTTP: GET
METHOD: books/genres
RESPONSE: Liste von Genres mit Icon, ID, Namen und Anzahl der Bücher

Suche

Bei der Suche wird der Suchbegriff in die URL einkodiert, als zweiter Teil der Methode.

HTTP: GET
METHOD: search/QUERY
RESPONSE: Liste von Such-Ergebnissen, wichtig ist hier vorallem die Buch-ID

Buch-Informationen

HTTP: GET
METHOD: books/BUCH-ID
RESPONSE: Ausführlichere Informationen über das Buch, unter anderem mit Beschreibung von Autor und Buch

Download

HTTP: GET
METHOD: getfile/BUCH-ID
RESPONSE: Verschlüsseltes eBook

Vom Buchtitel zum eBook

Nun weiß ich, wie ich ein Buch suchen und herunterladen kann, aber irgendwie öffnet Calibre die nicht? Also habe ich einen kleinen Blick in die App selbst geworfen und siehe da: es gibt eine Java-Klasse namens DecryptUtils, klingt nach dem richtigen. In dieser Klasse gibt es zwei Funktionen, die erste konnte JD-GUI leider nicht lesen. Aber die zweite Funktion mit dem vielversprechenden Namen getCypherKey ist lesbar und besteht nur aus einer Zeile:

return Utils.md5(new StringBuilder(paramString1 + "#readfy#" + paramString2).reverse().toString());

Und wenn wir mal schauen, wo diese aufgerufen wird, finden wir schnell die ResourceLoader.load-Funktion, die scheinbar die Datei eines einzelnen Buches läd. Dort sieht der Aufruf so aus: getCypherKey(paramString, UserUtils.getUserId(). Daraus wissen wir, dass die beides Parameter scheinbar die Buch-ID und die User-ID sind. Jetzt können wir schon den CypherKey generieren, aber wie entschlüsseln wir jetzt das Buch?

Dazu schauen wir einfach mal in die smali-Variante der DecryptUtils-Klasse. Hier sehen wir zuerst bei der getCypherKey-Funktion, dass die Parameter tatsächlich Buch-ID und User-ID sind. Zuerst macht diese Dinge mit FileStreams, das interessiert uns nicht. Die relevanten Zeilen sind

const-string v8, "AES/CBC/NoPadding"

invoke-static {v8}, Ljavax/crypto/Cipher;->getInstance(Ljava/lang/String;)Ljavax/crypto/Cipher;

Hier wird das Cipher-Objekt erstellt, mit dem schliesslich das Buch entschlüsselt wird. Dadurch wissen wir, dass AES im CBC-Modus genutzt wird. Der IV wird als die ersten 16 Byte des zu dekodierenden Datenstroms gesendet.

Nun können wir das ganze einfach in wenigen Zeilen Python-Code dekodieren, wie es in der decode-Funktion in readfy.py gemacht wird:

def decrypt( self, enc, book_id ):
    raw_key = str(book_id + "#readfy#" + self.user_id)[::-1]
    key = str(hashlib.md5(raw_key.encode()).hexdigest())[:32]
    iv = enc[:16]
    enc= enc[16:]
    try:
        cipher = AES.new(key.encode(), AES.MODE_CBC, iv)
        return cipher.decrypt(enc)
    except Exception as e:
        raise e

Und die dabei entstehende Datei ist einfach ein ePub, dass mit einer belibigen Software geöffnet werden kann. Und dann auch ganz ohne Werbung und offline.

# -*- encoding: utf-8 -*-
import sys
import requests
import hashlib
from Crypto.Cipher import AES
import shelve
import os
class Readfy:
session_token = None
def __init__(self, email, password, cache=True):
if cache:
with shelve.open("readfy.db") as db:
if email in db:
data = db[email]
for k,v in data.items():
setattr(self, k, v)
print("Welcome back, {}".format(self.user_name))
if not self.session_token:
token = self.login(email, password)
if cache:
with shelve.open("readfy.db") as db:
db[email] = token
print("Welcome back, {}".format(self.user_name))
def login(self, email, password):
data = {"user":email, "psw":password}
loginreq = requests.post("https://api.readfy.com/api2/users/login?session_token=null", json=data)
loginresp = loginreq.json()
loginresp = loginresp['response']
if loginresp['token']:
token = {}
token["user_id"] = loginresp['userId']
token["session_token"] = loginresp['token']
token["user_name"] = loginresp['username']
for k,v in token.items():
setattr(self, k, v)
return token
else:
raise Exception("Wrong username or password")
def decrypt(self, enc, book_id):
raw_key = str(book_id + "#readfy#" + self.user_id)[::-1]
key = str(hashlib.md5(raw_key.encode()).hexdigest())[:32]
iv = enc[:16]
enc = enc[16:]
try:
cipher = AES.new(key.encode(), AES.MODE_CBC, iv)
return cipher.decrypt(enc)
except Exception as e:
raise e
def getBookToEpub(self, book_id, path=None, outdir="out", title=None):
bookurl = "https://api.readfy.com/api2/getfile/" + str(book_id) + "?session_token=" + str(self.session_token)
bookreq = requests.get(bookurl)
book = bookreq.content
if path == None:
if not os.path.isdir(outdir):
os.mkdir(outdir)
path = "{}/{}.epub".format(outdir, book_id)
if not title:
nameurl = "https://api.readfy.com/api2/books/" + str(book_id) + "?session_token=" + str(self.session_token)
namereq = requests.get(nameurl)
try:
nameresp = namereq.json()
title = nameresp['response']['title']
except:
print(namereq.text)
sys.exit(1)
if title:
name = title.replace(" ", "_").replace("/", "_")
path = "{}/{}.epub".format(outdir, name)
with open(path, "wb") as bookfile:
bookfile.write(self.decrypt(book, book_id))
def search(self, query):
searchurl = "https://api.readfy.com/api2/search/" + str(query) + "?session_token=" + str(self.session_token)
searchreq = requests.get(searchurl)
resp = searchreq.json()
return resp
def genres(self):
url = "https://api.readfy.com/api2/books/genres?session_token=" + str(self.session_token)
rep = requests.get(url)
return rep.json()
def getGenre(self, genre_id, num_book):
url = "https://api.readfy.com/api2/books/genre/" + genre_id + "/" + num_book + "?session_token=" + str(self.session_token)
rep = requests.get(url)
return rep.json()
if __name__ == "__main__":
email = ""
password = ""
if not email or not password:
print("Wenn du deine eMail im Script einträgst, musst du sie nicht bei jedem Start neu eingeben.")
if not email:
email = input("Bitte eMail eingeben: ")
if not password:
password = input("Bitte Passwort eingeben: ")
assert email and password, "eMail und Passwort müssen eingegeben werden"
readfy = Readfy(email, password, cache=True)
term = input("Wonach soll ich suchen? ")
print("Ok, es wird nach {} gesucht".format(term))
search_result = readfy.search(term)
print("Es wurden {total_books} Bücher gefunden.".format(**search_result))
if int(search_result["total_books"]) > 0:
for i,book in enumerate(search_result["response"]):
authors = ", ".join(author["name"] for author in book["authors"])
print("{nr}) {author}: {title}".format(nr=i, author=authors, **book))
book = int(input("Welches Buch möchtest du herunterladen? "))
book_id = search_result["response"][book]["id"]
title = search_result["response"][book]["title"]
print("{title} wird herunterladen.".format(**search_result["response"][book]))
readfy.getBookToEpub(book_id, title=title)
pycryptodome
requests
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment