Cydiás iOS-fejlesztéssel kapcsolatos sorozatunk e részében a rendszer és az alkalmazások módosításához elengedhetetlenül szükséges alapismereteket tesszük közzé.
Az Objective-C runtime library használata
Amint azt már az előző részben is említettem: az Apple nagy szívességet tett a jövőbeli cydiás fejlesztőknek (valószínűleg az első és utolsó szívesség, amit a fejlesztők az Apple-től kaptak…), amikor az Objective-C nyelvet választotta az alkalmazások „anyanyelvéül”. Ez a programozási nyelv ugyanis beépített támogatást nyújt az egyes osztályok és objektumok tulajdonságának futásidejű, „önreflexív” módosítására a runtime library, azaz a libobjc API-jainak segítségével.
Na de mire is jó mindez?
Először is, ha akár csak egy hivatalos App Store-ba készülő alkalmazást fejlesztünk, akkor is jól jöhet a futásidejű „introspekció”, ahogy az angolszász szakirodalom szereti emílteni ezt a funkcionalitást. Tegyük fel például, hogy alkalmazásunk egy JSON-alapú webservice-szel kommunikál, ami a különböző lekérdezésekre különböző típusú adatokkal válaszol (ez ténylegesen egy valós probléma). Tekintsük például a következő esetet: bizonyos esetekben (például: „Keressük a legfinomabb gyümölcsöt”) elég egyetlen objektum visszaadása:
{
„fruit”: „apple”,
„tastiest”: true,
„origin”: „California”
}
Másokban viszont egy listával vagy tömbbel kell visszatérni (például: „Keressük a finom gyümölcsöket”), tehát valami ilyesmi:
{
{
„fruit”: „apple”,
„tastiest”: true,
„origin”: „California”
},
{
„fruit”: „orange”,
„tastiest”: false,
„origin”: „Paris”
}
]
A jól képzett iOS-fejlesztő ekkor gyorsan belenyúl virtuális zsebébe, előhúzza az NSJSONSerialization osztályt, és már kap is egy frissen sült narancsos-almás objektumot:
id <NSObject> obj = [NSJSONSerialization JSONObjectWithData:jsonData options:kNilOptions error:NULL];
Igen ám, csakhogy nem tudjuk konkrétan, hogy milyen objektumot kaptunk vissza: lehet az NSArray vagy NSDictionary, a választól függően. Ekkor nyúlhatunk az Objective-C runtime-hoz, és megkérdezhetjük az objektumtól, hogy milyen osztályba is tartozik:
if ([obj isKindOfClass:[NSArray class]]) {
// egy egész raklapnyi gyümölcs (tömb)
} else if ([obj isKindOfClass:[NSDictionary class]]) {
// egyetlen szem alma
} else {
// romlott gyümölcs (ha egyik sem: hiba történt)
}
Ez már valóban egy olyan lehetőség, ami a dinamizmusnak köszönhető. Statikusan típusos programozási nyelvekkel (mint például a C) hasonló trükköt, tudniillik egy érték típusának a futásidejű meghatározását, nem lehet megvalósítani.
Egy másik fontos művelet lehet egy metódushoz tartozó függvény (ugye tudjátok, hogy minden Objective-C-metódus valójában egy sima C függényként kerül implementálásra?) lekérése, a metódusnév („selector”) alapján. Még itt sem igazán látszik, hogy az Objective-C runtime-mal dolgoznánk, hiszen, ahogyan az előző példában is, csupán az NSObject osztályt fogjuk használni. A magyarázat teljesen egyszerű: az NSObject sok olyan „beépített” metódust tartalmaz, amik csupán az Objective-C runtime körüli, kényelmi célokat szolgáló úgynevezett wrapperek. Álljon itt tehát, hogyan nyerhetjük ki egy selector segítségével egy adott metódus tényleges implementációját:
// Itt kapunk egy pointert az implementációra
IMP imp = [Osztaly instanceMethodForSelector:@selector(foo)];
Osztaly *obj = [[Osztaly alloc] init];
imp(obj, @selector(foo)); // itt pedig meghívjuk egy konkrét példányon
[obj release];
Az IMP egy „függvénymutató” (angolul jobban hangzik: function pointer) típus. Ha esetleg a C nyelvben való jártasságunk idáig már nem terjed, akkor gyorsan tanuljuk meg használni őket – a tweakfejlesztésben kikerülhetetlen szerepet játszanak.
Hogy végre a „csupasz” Objective-C runtime is látható legyen, íme az előzővel ekvivalens kód, közvetlenül a libobjc API felhasználásával:
Class oszt = objc_getClass(„Osztaly”);
// vagy ez:
Method met = class_getInstanceMethod(oszt, @selector(foo));
IMP imp = method_getImplementation(met);
// vagy rövidebben:
IMP imp = class_getMethodImplementation(oszt, @selector(foo));
// innentől kezdve pedig minden ugyanaz
Ha még ennél is tovább akarunk menni, akkor akár a felhasználótól is bekérhetünk egy tetszőleges stringet, és megpróbálhatjuk végrehajtani azt egy objektumon (a „textField” változó itt egy érvényes, inicializált UITextField példány kell, hogy legyen, amibe a felhasználó ír):
NSString *str = textField.text;
SEL sel = NSSelectorFromString(str); // selector (metódusnév) a felhaszánló által beírt szövegből
Osztaly *obj = [[Osztaly alloc] init];
if ([obj respondsToSelector:sel]) {
[obj performSelector:sel];
}
[obj release];
Természetesen a valós életben, élesben ne tegyünk ilyet, mert ez komoly biztonsági kockázatot jelentene. Itt ez csak a teljesség és az érdekesség kedvéért áll.
Majdnem ugyanezt a csíntalanságot elkövethetjük az „instance variable” megnevezésű, példányokhoz kötött változók esetén is. Ez az én egyik személyes kedvencem is egyébként, már csak a már-már perverz módon kihasznált pointeraritmetika miatt is:
@interface Osztaly: NSObject {
MasikOsztaly *peld_valtozo;
CGRect valami_negyzet;
}
@end
// …
// Itt a függvényünk, ami a lényegi munkát végzi:
void *object_getIvarPointer(id obj, const char *name)
{
Class cls = object_getClass(obj);
Ivar ivar = class_getInstanceVariable(cls, name); // kiolvassuk az ivart reprezentáló Ivar struktúrát
char *base_addr = (char *)obj;
return base_addr + ivar_getOffset(ivar);
// majd visszatérünk az objektum címéből és az Ivar struktúrából számolt abszolút címmel
}
// Itt pedig az, hogyan kell használni:
Osztaly *obj = [[Osztaly alloc] init];
void *ivarAddr;
MasikOsztaly *stolenIvar;
ivarAddr = object_getIvarPointer(obj, „peld_valtozo”
stolenIvar = *(MasikOsztaly **)ivarAddr;
CGRect rect;
ivarAddr = object_getIvarPointer(obj, „valami_negyzet”);
rect = *(CGRect *)ivarAddr;
Fontos megjegyezni, hogy ugyan az Objective-C runtime tartalmaz beépített függvényeket a „példányváltozók” olvasására és írására, ezek a föggvények mind a generikus „id” típussal dolgoznak, ami – mint tudjuk – egy pointertípus. Ez azt jelenti, hogy a pointernél nagyobb méretű értékek esetében (iOS-en ez a 32 bites architektúra miatt a 64 bites egészeket, a szintén 64 bites „double” típust, valamint természetesen a megfelelő méretű „struct” struktúrákat jelenti) nem használhatóak, míg a fenti függvény univerzális.
A következő lépésben még tovább megyünk típusok terén. Természetesen nem csak a változóknak, objektumoknak van típusa, hanem a metódusoknak és a függvényeknek is. Ezekről is kaphatunk információt a runtime segítségével (a releváns StackOverflow-kérdést és -választ is érdemes elolvasni):
Method
m = class_getInstanceMethod([
Osztaly
class
],
@selector
(foo:bar:));
char
type[
128
];
method_getReturnType(m, type,
sizeof
(type));
Mi is történik itt tulajdonképpen? Az első sorban a metódust leíró Method struktúrát érjük el. A második sorban egy 128 karakternyi tömböt foglalunk le, majd a harmadik sor a Method struktúrából kinyeri a típusra vonatkozó információt.
(Fontos megjegyezni, hogy az eddigi néhány példában csak példánymetódusokra vonatkozó példákat hoztam, osztálymetódusokról nem volt szó. Ennek pusztán az az oka, hogy Objective-C-ben az osztálymetódusok is példánymetódusok, miután az osztályok is objektumok: a saját metaosztályuk példányai. Az „NSObject” osztály „+alloc” nevű metódusa tehát gyakorlatilag felfogható az „NSObject” metaosztály „-alloc” metódusaként is. Az összes fenti példában tehát az osztályokat a metaosztályukra kicserélve dolgozhatunk osztálymetódusokon is.
De mi is lesz mindezek után a „type” bufferben? Az Objective-C runtime egy, az összes (teljes) C és Objective-C típust reprezentálni képes típusleíró „nyelvet” is tartalmaz (teljes dokumentáció) (az „incomplete”, avagy „forward-declared” típusokra ez a típusleírás nem alklamazható). A lényeg, hogy minden egyes primitív típus egy-egy betűnek felel meg, az „összetett” típusok (pl. tömbök, struktúrák, objektumok) leírésa pedig ezek kombinációjával, néhány kiegészítő jelölés segítségével történik. Példák:
i → int
* → char *
f → float
: → SEL
@ → id
^{StructName=ci} → struct StructName { char ch; int i; } *
[128^i] → int[128]
Ennek a tudásnak a segítségével már továbbléphetünk egy még érdekesebb kérdéskörre: ne csak olvassuk a program adatait, hanem módosítsuk is azokat! Itt elsősorban az osztályok és metódusok viszonyáról van szó. Az egyik fontos manifesztációja a runtime ilyen célú felhasználásának az osztályokhoz való metódusok dinamikus hozzáadása. Miért lenne ilyesmire egyáltalán szükség, tehetjük föl a jogosnak látszó kérdést, mikor kategóriákat és „class extension”-öket is írhatunk? Nos, a válasz: a kategóriák és class extensionök készítése csak akkor lehetséges, ha az Objective-C compiler (pontosabban a linker) hozzáfér az ily módon kiterjeszteni kívánt osztály definíciójához (azaz vagy mi írtuk az eredeti osztályt is, és megvan a forráskódja, vagy egy library vagy framework exportálja, mint nyilvános, linkelhető szimbólumot). Ez pedig tweakek írása esetén általában nem igaz. Ha például egy olyan osztályt szeretnénk kiegészíteni, ami a SpringBoard részét képezi, akkor ahhoz a linker semmilyen formában nem tud hozzáférni. Így tehát nem marad más, mint a metódus teljesen dinamikus hozzáadása (ezt a trükköt kellett egyébként alkalmaznom a libipodimport szerveroldali részének megírásakor, pontosan ebből az okból kifolyólag):
const char *_SBUIController_$_foo_bar_(id self, SEL _cmd, id arg1, int arg2)
{
NSString *str = [NSString stringWithFormat:@”%@ %d”, arg1, arg2];
return strdup(str.UTF8String);
}
class_addMethod(
objc_getClass(„SBUIController”),
@selector(foo:bar:),
(IMP)_SBUIController_$_foo_bar_,
„*@:@i”
);
Magyarázat: a _SBUIController_$_foo_bar_ függvény adja a létrehozandó metódus implementációját, azaz a tényleges kódot, ami a metódushíváskor végrehajtódik. Ez a függvény pusztán annyit tesz, hogy megfelelően megformázva egy C stringben visszaadja két argumentumának emberi olvasásra is alkalmas leírását.
Az érdekes rész a „class_addMethod()” függvény hívása. Ennek az első argumentuma az osztály, amihez a metódust szeretnénk adni. Figyeljük meg, hogy itt nem használhatjuk az [SBUIController class] formát, hiszen az SBUIController osztály nem érhető el a linker számára, tekintve, hogy a SpringBoard egyik osztálya – a fordítás során tehát linker errort kapnánk (a híres-hírhedt „Undefined reference to symbol _$_OBJC_CLASS_$_SBUIController” hibaüzenetet).
A második argumentum egyszerűen a selector, a metódus neve, a harmadik pedig a függvénypointer, ami az implementációt tartalmazza. Az előző részben leírt típusleírásnak pedig a negyedik argumentum megírása során vesszük hasznát: a runtime itt a metódus típusát („function signature”) leíró sztringet vár. Ennek megkonstruálására a szabály: a legelső karakter a visszatérési értéket jelenti, a többi pedig az argumentumokét sorban. Ennek az is a következménye, hogy a class_addMethod() használata során a típussztring 2. és 3. karaktere mindig „@” és „:” lesz, hiszen ezt a két első implicit argumentumot minden Objective-C metódusként használt függvény megkapja.
A függvény meghívása után ugyanúgy használhatjuk a metódust, mintha azt kézzel implementálták volna:
char *str = [[SBUIController sharedInstance] foo:nil bar:1337];
A legutolsó műveletet nem véletlenül hagytam a sor végére. Ez a legösszetettebb dolog, amit praktikusan csinálni fogunk a runtime segítségével, ez igényli a legtöbb figyelmet, valamint ezt fogjuk a legtöbbször használni, habár nem is pontosan ilyen formában. Amiről most szó lesz, az metódusimplementációk cseréje. A szakirodalomban „hooking”, „method swizzling” és hasonló félelmetes neveken említik. A koncepció első ránézésre ennek ellenére meglehetősen egyszerű: vegyünk egy függvényt, és cseréljük ki erre a függvényre egy már meglévő metódus implementációját. Ez azt jelenti, hogy innentől az a metódus mást fog csinálni, amikor meghívják, mint amire eredetileg tervezték. Ugye ez már egészen hasonlít a tweakek működésére, nem igaz? 🙂
Csináljunk egy rossz heccet (persze csak gondolatban, vagy abban az esetben, ha ellopták a készülékünket): tegyük lehetetlenné a készüléken lévő alkalmazások megnyitását.
Nehezítés: valósítsuk meg mindezt kalapács és tömény salétromsav használata nélkül.
A nehezítés miatt a készülék fizikai tönkretételét jó közelítéssel kizártuk, marad tehát a bonyolultabb, ám elegánsabb megoldás: az ikonokat az Objective-C runtime segítségével rávesszük arra, hogy ne csináljanak semmit, ha megnyomják őket.
Ha közelebbről megnézzük a SpringBoard belső felépítését (azt, hogy ezt hogyan lehet viszonylag egyszerűen megtenni, a következő részben meg fogjátok tudni), találunk egy SBApplicationIcon nevű osztályt. Ez az osztály pontosan arra való, amire a neve utal: a SpringBoardon egy alkalmazás ikonját testesíti meg. Rendelkezik ez az osztály egy „-launch” nevű metódussal, ami szintén logikus nevet kapott: ez indítja az alkalmazást az ikonra való tapizáskor. Ha tehát ezt a metódust kicseréljük egy NOP-ra, akkor nem fognak indulni az alkalmazások. Első körben valami ilyesmiről lehet szó:
Class _$SBApplicationIcon = objc_getClass(„SBApplicationIcon”);
static IMP _original_$_SBApplicationIcon_$_launch;
void _modified_$_SBApplicationIcon_$_launch(id self, SEL _cmd)
{
// nem csinálunk semmit
// ha mégis meg akarnánk nyitni az alkalmazást,
// akkor a régi, lecserélt implementációt meghívhatnánk:
// _original_$_SBApplicationIcon_$_launch(self, _cmd);
}
Method m = class_getInstanceMethod(_$SBApplicationIcon, @selector(launch));
_original_$_SBApplicationIcon_$_launch = method_setImplementation(m, (IMP)_modified_$_SBApplicationIcon_$_launch);
Az első két sor ismét magáért beszél: a megfelelő osztályt kinyerjük a runtime segítségével, majd deklarálunk egy globális változót (ejnye, nem szép dolog…) az eredeti implementáció tárolására. A következő pár sor csak az üres függvény definiálására szolgál, majd a szokásos class_getInstanceMethod() után végül a lényeg: a method_setImplementation() függvény. Ez a függvény nem tesz mást, mint az első argumentumaként átadott metódus implementációját a második argumentumban átadott függvénypointerre változtatja, majd visszatér az eredeti implementációra mutató pointerrel, így az eredeti implementáció sem veszik el. Ezt meghívhatjuk (és általában meg is kell hívnunk) a módosított, „modified” függvényből.
A MobileSubstrate pontosan ezt a módszert alkalmazza, csak rengeteg, itt ki nem fejtendő biztonsági lépést tesz annak érdekében, hogy a tweakek ne „akadjanak össze” egymással és a rendszerrel,. Ez az, amiért általában érdemes a MobileSubstrate-hez fordulni az Objective-C runtime közvetlen meghívása helyett. Azt pedig, hogy pontosan hogyan használhatjuk a MobileSubstrate API-jait, megtudjátok két rész múlva, amikor is elkészítjük első, ténylegesen működő, komplett tweakünket!
4 Comments
Én mondjuk c#-ban szoktam programozni ( igaz nem ios-re) de ott is nagyon tetszett a reflexió, igencsak hasznos lehet néhány esetben, például amiket te is emlitesz a cikkben. Egyébként Gratula hozzá és kitartást, szerintem nagyon jó mindkét eddigi része.
@Cyberbird: Köszönöm 🙂
(A cikkeknél minden OFF-topic hozzászólást törlünk. Erre van a kereső a jobb felső sarokban, illetve a Gyakran Ismételt Kérdések cikk. Kérünk, használd a keresőt, vagy ha az nem ad eredményt, a linkelt cikknél tedd fel a kérdésed!)
(A cikkeknél minden OFF-topic hozzászólást törlünk. Erre van a kereső a jobb felső sarokban, illetve a Gyakran Ismételt Kérdések cikk. Kérünk, használd a keresőt, vagy ha az nem ad eredményt, a linkelt cikknél tedd fel a kérdésed!)