9

去哪儿 Android 客户端隐私安全处理方案

 3 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzA3NDcyMTQyNQ%3D%3D&%3Bmid=2649266010&%3Bidx=1&%3Bsn=bd2c274111533f9369d0e644d72dd0ab
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

点击蓝字

关注我们

作者简介

FZR77j.jpg!mobile

江保贵

去哪儿网前端架构师

2011年4月加入去哪儿网,目前在基础研发大前端团队,专注于移动端质量效率提升,监控体系设计搭建等工作。先后多次参与客户端框架改版设计、制定开发规范,设计开发移动端差分升级系统、移动端交互日志&性能采集系统、渠道快速打包自动发布系统等,喜欢积极向上的团队氛围、不断学习新技术、追求技术创新带来的效率及性能提升。

1 背景

2019年央视3.15晚会曝光的个人隐私通过手机 app 泄露的案例令人触目惊心,作为一个应用开发者为了快速实现功能的快速开发,需要使用如广告、推送、统计、定位/地图、支付、社交等功能时都会引用相关的第三方 SDK,这些 SDK 很少以源码方式提供,也就是说这些 SDK 内执行逻辑对开发者来说是未知的,这些 SDK 自身滥用或者有安全漏洞,非法收集应用和用户隐私信息、远程下发执行恶意代码等造成的后果将不堪设想。

这里我分享一下去哪儿网如何通过技术手段,管控自身和三方 SDK 获取隐私信息的,因篇幅较长,先讲一下我们实现的功能特性及优势,后边详细介绍一下技术实现细节。

baia2yb.jpg!mobile

2 功能特性及优势

1、全局监控

应用自身和第三方 SDK 对敏感 API 调用,目前已经监控的如下:

  • 网络请求

  • 请求申请应用权限

  • 读取设备应用列表

  • 读取 Android SN(Serial)

  • 读写联系人、通话记录、日历、本机号码

  • 获取定位、基站信息

  • Mac 地址、IP 地址

  • 读取 IMEI(DeviceId)、MEID、IMSI、ADID(AndroidID)

2、全面高效

不需要升级更新原有 SDK 版本依赖和更改业务逻辑代码,不用更改原有开发模式,只需要一次配置,无论之前或者以后新增 SDK 都可以监控。

3、开发简单

新增监控开发简单,一个自定义方法+一行注解,编译运行就能生效。

4、对工具库无版本依赖

工具库有哪些方法,就 hook 哪些 API,不需要复杂的版本依赖判断。

5、可扩展性强

工具方法自定义,可记录调用堆栈、返回空数据等可自主控制。

3 初步方案

我们首先考虑在 Android 项目编译器使用自定义的 Android Transform,全局 hook 相关 API 调用,替换为我们自定义的方法进行限制,这样无论是 javaClass 还是 jar 都可以控制。

neqaqyI.png!mobile

AOP 操作字节码可用的技术很多,如 ASM、AspectJ、javassit 等,这里我们用 ASM 做一个简单替换原生获取 IMEI 的例子:

原始方法调用:

TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);  
telephonyManager.getDeviceId();

要实现的效果:

 TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);  
 //调用我们的工具类QTelephonyManager
 QTelephonyManager.getDeviceId(telephonyManager);

我们跳过 Android Transform 部分介绍(不了解的可以看一下 Transform 详解:https://www.jianshu.com/p/37a5e058830a),直接用 ASMPlugin 中 ASMified 查看 class 字节码,对比前后发生变化,修改原来的字节码:忽略相同代码,原始方法 ASMCode 如下:

//... 
methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "android/telephony/TelephonyManager", "getDeviceId", "()Ljava/lang/String;", false);

原始方法ASMCode解析:

  • methodVisitor 是方法解析器

  • visitMethodInsn 代表解析方法调用

  • INVOKEVIRTUAL 是方法修饰符,代表调用实体类的方法

  • android/telephony/TelephonyManager 代表的是调用该方法的 owner

  • getDeviceId 代表的是调用的方法名称

  • ()Ljava/lang/String;() 代表的是方法的参数,后边是返回值

需要替换成我们的方法对应的 ASMCode 如下:

//...
methodVisitor.visitMethodInsn(INVOKESTATIC, "com/mqunar/goldcicada/lib/QTelephonyManager", "getDeviceId", "(Landroid/telephony/TelephonyManager;)Ljava/lang/String;", false);

调用自定义方法ASMCode解析

  • INVOKESTATIC 替换实体类方法调用为静态方法

  • 方法所在的类替换为我们自定义的工具类 com/mqunar/goldcicada/lib/QTelephonyManager

  • 方法名称我们保持跟原生方法一致

  • 方法的参数和返回值,我们修改为接收 android/telephony/TelephonyManager,返回值保持不变

自定义工具类 QTelephonyManager 代码实现:

    public static String getDeviceId(TelephonyManager telephonyManager) {
if (!GoldCicada.isCanRequestPrivacyInfo()) {
// print or record StackTrace
return "";
}
return telephonyManager.getDeviceId();
}


Transform中对字节码处理代码实现:

public static boolean shouldInject = false
final static def QTelephonyManager = 'com/mqunar/goldcicada/lib/QTelephonyManager'
final static def TelephonyManager = "android/telephony/TelephonyManager"
static byte[] transform(byte[] bytes) {
def classNode = new ClassNode()
new ClassReader(bytes).accept(classNode, 0)//读取字节码,结果存放到classNode中
classNode = transform(classNode)//操作classNode处理class字节码
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS)
classNode.accept(cw)
return cw.toByteArray()
}
static ClassNode transform(ClassNode klass) {
if (!shouldInject) {//开关控制
return klass
}
// 检测到是自己工具类时不注入
if (klass.name.startsWith(QTelephonyManager)) {
return klass
}
klass.methods?.each { MethodNode method ->
method.instructions?.iterator()?.each { AbstractInsnNode insnNode ->
if (insnNode.opcode == INVOKEVIRTUAL
&& insnNode.owner == TelephonyManager
&& insnNode.name == "getDeviceId"
&& insnNode.desc == "()Ljava/lang/String;") {
QBuildLogger.log "QHOOK ${insnNode.owner}.${insnNode.name}${insnNode.desc} @ ${klass.name}.${method.name}${method.desc}"
insnNode.owner = QTelephonyManager
insnNode.opcode = INVOKESTATIC
insnNode.desc = "(L${TelephonyManager};)Ljava/lang/String;"
}
}
}
return klass
}

这种方案实现了全局替换的功能,但是方案的缺点很明显:

  • Transform脚本代码硬编码判断;

  • 对工具类库有依赖,如果工具类库新增了 hook 类或者方法,对老版本打包的时候就需要做版本判断,要不然运行时就报 ClassNotFoundException;

  • 开发需要对 ASM 字节码编程有一定了解,每次新增 hook 都需要对比前后变化进行修改验证,工作量比较大。

有没有一种方法,同时解决以上三个问题?

4 进阶方案

要解决应上边遇到的问题,首先我们要考虑使用一个通用的配置,Transform 的字节码处理器不用关心具体要 hook 的类和方法,这里我们使用到自定义注解,在自定义方法上加上注解,告诉我要替换什么类和方法、该方法是静态、非静态,方法参数等。然后经过 Transform 先把加了自定义注解的所有配置读取出来,生成一份需要 hook 的方法配置列表。再次 Transform 根据配置进行 hook,就初步解决以上三个问题。流程图如下:

YfMvayZ.jpg!mobile

大家可能有一个疑问:自定义注解都是和注解处理器一起使用的,你这里为什么使用一个 Transform 进行注解的读取和解析?

其实大家用过注解处理器就知道:注解处理器处理最大的优点是可以在生成 class 字节码之前执行,处理的是 java 源码,此时可以结合 javapoet 动态生成 java 源代码;缺点是每个使用注解的项目都需要配置注解处理器,这样首先配置就比较麻烦,再一个结合我们这个项目,注解的作用是给 Transform 用的,这时候就适合在 App 打包时用 Android Transform 先生成一份配置,接着再交给下一个 Transform 进行 Hook。

4.1 创建一个自定义注解库 apt-annotation

声明自定义注解 AsmField,声明三个参数

  • @Retention 表示的是注解生效的生命周期,RetentionPolicy.CLASS 表示注解被保留到 class 文件,但 jvm 加载 class 文件时候被遗弃

  • oriClass 代表需要 hook 的类

  • oriMehod 代表被 hook 的方法名称,默认与工具类方法名一致

  • oriAccess 方法类型,默认是 static 方法

  • 方法参数和返回值由原方法的类型和自定义方法决定,后边在注解处理器初详细描述

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface AsmField {
Class oriClass();
String oriMehod() default "";
MethodAccess oriAccess() default MethodAccess.INVOKESTATIC;
public enum MethodAccess {
/** 静态方法 static */
INVOKESTATIC(Opcodes.INVOKESTATIC),
/** 接口方法,如调用 onClickListener.onClick(view) */
INVOKEINTERFACE(Opcodes.INVOKEINTERFACE),
/** 实体类方法 */
INVOKEVIRTUAL(Opcodes.INVOKEVIRTUAL),
/** 调用super方法 */
INVOKESPECIAL(Opcodes.INVOKESPECIAL);
int value;
MethodAccess(int value) {
this.value = value;
}
public int value() {
return value;
}
}
}

4.2 工具类库处理 toolsLibrary

  • 让工具类库 toolsLibrary 依赖我们的 apt-annotation

  • 在工具类 QTelephonyManager 的自定义方法上增加注解

 @AsmField(oriClass = TelephonyManager.class, oriAccess = MethodAccess.INVOKEVIRTUAL)
public static String getDeviceId(TelephonyManager telephonyManager) {
if (!GoldCicada.isCanRequestPrivacyInfo()) {
// print or record StackTrace
return "";
}
return telephonyManager.getDeviceId();
}

4.3 主项目依赖工具库

  • 在主项目中 build.gradle 的 dependencies 中配置上 toolsLibrary 项目

implementation project(":toolsLibrary")

4.4 自定义 Android 插件 AnnotationParserTransform 读取注解,生成配置列表

  • 解析注解 AnnotationParserTransform 核心代码如下:

//
static void parseAsmAnnotation(byte[] bytes) {
def klass = new ClassNode()
new ClassReader(bytes).accept(klass, 0)//读取字节码,结果存放到classNode中
klass.methods.each { method ->
method.invisibleAnnotations?.each { node ->
if (node.desc == 'Lcom/mqunar/qannotation/AsmField;') {
asmConfigs << new AsmItem(klass.name, method, node)
}
}
}
}
static class AsmItem {
public String oriClass
public String oriMethod
public String oriDesc
public int oriAccess = Opcodes.INVOKESTATIC


public String targetClass
public String targetMethod
public String targetDesc
public int targetAccess = Opcodes.INVOKESTATIC


public AsmItem(String targetClass, MethodNode methodNode, AnnotationNode node) {
this.targetClass = targetClass
this.targetMethod = methodNode.name
this.targetDesc = methodNode.desc
String sourceName
for (int i = 0; i < node.values.size() / 2; i++) {
def key = node.values.get(i * 2)
def value = node.values.get(i * 2 + 1)
if (key == 'oriClass') {
sourceName = value.toString()
oriClass = sourceName.substring(1, sourceName.length() - 1)
} else if (key == 'oriAccess') {
this.oriAccess = Opcodes."${value[1]}"
} else if (key == "oriMethod") {
this.oriMethod = value
}
}
if (this.oriMethod == null) {
this.oriMethod = targetMethod
}
if (this.oriAccess == Opcodes.INVOKESTATIC) {//静态方法,参数和返回值一致
this.oriDesc = targetDesc
} else {
String param = targetDesc.split("\\)")[0] + ")"
String returnValue = targetDesc.split("\\)")[1]
if (param.indexOf(sourceName) == 1) {
param = "(" + param.substring(param.indexOf(sourceName) + sourceName.length())
}
this.oriDesc = param + returnValue
}
}
}

4.5 自定义 Android 插件 ASMTransform 根据配置替换字节码

  • 根据配置列表,优雅地进行字节码替换

class AsmInjectProxy {
static byte[] transform(byte[] bytes) {
if (AsmAnnotationParser.asmConfigs.isEmpty()) {
return bytes
}
def classNode = new ClassNode()
new ClassReader(bytes).accept(classNode, 0)//读取字节码,结果存放到classNode中
classNode = transform(classNode)
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS)
classNode.accept(cw)
return cw.toByteArray()
}


/**
* 对ClassNode对象做处理
* @param klass
* @return
*/
static ClassNode transform(ClassNode klass) {
def asmItems = AsmAnnotationParser.asmConfigs
for (def it : asmItems) {
if (klass.name.startsWith(it.targetClass)) {
return klass//目标类不注入
}
}
klass.methods?.each { MethodNode method ->
method.instructions?.iterator()?.each { AbstractInsnNode insnNode ->
if (insnNode instanceof MethodInsnNode) {
asmItems.each { asmItem ->
if (asmItem.oriDesc == insnNode.desc && asmItem.oriMethod == insnNode.name) //
&& insnNode.opcode == asmItem.oriAccess && insnNode.owner == asmItem.oriClass) {
insnNode.opcode = asmItem.targetAccess
insnNode.name = asmItem.targetMethod
insnNode.desc = asmItem.targetDesc
insnNode.owner = asmItem.targetClass
println "QHOOK ${insnNode.owner}.${insnNode.name}${insnNode.desc} @ ${klass.name}.${method.name}${method.desc}"
}
}
}
}
}


return klass
}
}

至此我们使用通用配置、动态替换的目标已经实现,基本上能实现80%以上的需求,但是随着功能需求复杂度增加,如 hook 创建对象 new Instance,子类用 super 调用父类方法,子类用 this. 调用父类有的方法等,上边的功能无法实现,因此我们丰富完善支持更复杂的功能。

5 超级HOOK

5.1 子类使用 super 关键字调用父类的方法

如 Hook 自定义 MainActivity 中调用获取权限方法 super.requestPermissions:我们依然提供一个 public static 方法与 Activity 一样的 requestPermissions 方法,增加注解@AsmField(oriClass = Activity.class,oriAccess = MethodAccess.INVOKESPECIAL)。

 @AsmField(oriClass = Activity.class, oriAccess = MethodAccess.INVOKESPECIAL)
public static void requestPermissions(Activity activity, String[] permissions, int requestCode) {
if (!GoldCicada.isCanRequestPrivacyInfo()) {
// print or record StackTrace
return;
}
//避开自己方法调用,调用父类方法
ReflectUtils.invokeSuperMethod(activity, "requestPermissions", new Class[]{String[].class, Integer.TYPE}, new Object[]{permissions, requestCode});
}

这样 super.requestPermissions(permissions, requestCode)编译后就被替换为QActivity.requestPermissions(permissions, requestCode)

大家注意,我们自定义方法中不能直接调用 activity.requestPermissions(permissions, requestCode)方法,因为这样调用是调用 MainActivity 自己的 requestPermissions 方法,如果 MainActivity 中重写了父类方法,并调用了 super.这样 hook 后就会死循环调用。我们这里的做法是,需要在工具类中反射调用父类的方法。

5.2 ReflectUtils 反射父类工具代码

 public static <T> T invokeSuperMethod(final Object obj, final String name, final Class[] types, final Object[] args) {
try {
final Method method = getMethod(obj.getClass().getSuperclass(), name, types);
if (null != method) {
method.setAccessible(true);
return (T) method.invoke(obj, args);
}
} catch (Throwable t) {
t.printStackTrace();
}
return null;
}


private static Method getMethod(final Class<?> klass, final String name, final Class<?>[] types) {
try {
return klass.getDeclaredMethod(name, types);
} catch (final NoSuchMethodException e) {
final Class<?> parent = klass.getSuperclass();
if (null == parent) {
return null;
}
return getMethod(parent, name, types);
}
}

5.3 子类调用父类方法,非super.

如 Hook 自定义 Activity 中调用获取权限方法 this.requestPermissions/requestPermissions:我们依然提供一个 public static 方法与 Activity 一样的 requestPermissions 方法,增加注解 @AsmField(oriClass = java.lang.Object.class,oriAccess = MethodAccess.INVOKEVIRTUAL)

 @RequiresApi(api = Build.VERSION_CODES.M)
@AsmField(oriClass = Object.class, oriAccess = MethodAccess.INVOKEVIRTUAL)
public static void requestPermissions(Object activity, String[] permissions, int requestCode) {
if (!GoldCicada.isCanRequestPrivacyInfo()) {
// print or record StackTrace
return;
}
if (activity instanceof Activity) {
((Activity) activity).requestPermissions(permissions, requestCode);
} else {//非Activity hook,反射调用
ReflectUtils.invokeMethod(activity, "requestPermissions", new Class[]{String[].class, Integer.TYPE}, new Object[]{permissions, requestCode});
}
}

这样 this.requestPermissions(permissions, requestCode)编译后就被替换为 QActivity.requestPermissions(permissions, requestCode)

大家注意 ,因为我们无法知道运行时的子类名称,因此我们也不知道被 hook 的类是谁,这里就用 Object 对象替代,并且参数也用 Object,在运行时判断传入的参数是否是 Activity 的子类,如果是就正常调用,不是的话就反射调用。

5.4 ReflectUtils 反射工具代码

    public static <T> T invokeMethod(final Object obj, final String name, final Class[] types, final Object[] args) {
try {
final Method method = getMethod(obj.getClass(), name, types);
if (null != method) {
method.setAccessible(true);
return (T) method.invoke(obj, args);
}
} catch (Throwable t) {
t.printStackTrace();
}


return null;
}

5.5 hook 创建对象方法

如我们想实现 okhttp 网络请求拦截,Hook 创建 new OkHttpClient.Builder()对象添加拦截器:

  • hook 之前,我们先查看 new 对象的 ASM 字节码和需要替换代码对比:

【变换前】

methodVisitor.visitTypeInsn(NEW, "okhttp3/OkHttpClient$Builder");
methodVisitor.visitInsn(DUP);
methodVisitor.visitMethodInsn(INVOKESPECIAL, "okhttp3/OkHttpClient$Builder", "<init>", "()V", false);
//...
methodVisitor.visitMaxs(2, 1);

【变换后】

methodVisitor.visitMethodInsn(INVOKESTATIC, "com/mqunar/goldcicada/lib/QOkHttpClient", "getOkHttpClientBuilder", "()Lokhttp3/OkHttpClient/Builder;", false);
//...
methodVisitor.visitMaxs(1, 1);

懵逼了,这里跟上边的所有的 hook 都不一样,这里三行代码 new 一个对象,后边还有一个数值变化了,这里我们就不仅仅替换,还需要移除修改代码。

我们先自定义一个 public static 方法,增加注解原始类和方法 @AsmField(oriClass = OkHttpClient.Builder.class, oriMehod = " <init> ", oriAccess = MethodAccess.INVOKESPECIAL), 这里大家注意  oriMehod 需要声明为<init>   ,oriAccess 需要声明为 MethodAccess.INVOKESPECIAL 。

  @AsmField(oriClass = OkHttpClient.Builder.class, oriMehod = "<init>", oriAccess = MethodAccess.INVOKESPECIAL)
public static OkHttpClient.Builder getOkHttpClientBuilder() {
OkHttpClient.Builder builder = new OkHttpClient.Builder();
builder.addInterceptor(chain -> {
if (!GoldCicada.isCanRequestPrivacyInfo()) {
// print or record StackTrace
return new Response.Builder().code(404).protocol(Protocol.HTTP_2)
.message("Can`t use network by GoldCicada")
.body(ResponseBody.create(MediaType.get("text/html; charset=utf-8"), ""))
.request(chain.request()).build();
}
return chain.proceed(chain.request());
});
return builder;
}

首先,我们在 AnnotationParserTransform 注解解析中特殊处理方法的返回值为 "V"

 if (oriAccess == Opcodes.INVOKESPECIAL && oriMethod == "<init>") {
returnValue = "V"
}

其次,我们在实现 hook 的 AsmTransform 中特殊处理 <init>

 klass.methods?.each { MethodNode method ->
Map<AbstractInsnNode, Object> needReplaceInitNode = [:] as Map
method.instructions?.iterator()?.each { AbstractInsnNode insnNode ->
if (insnNode instanceof MethodInsnNode) {
asmItems.each { asmItem ->
if (asmItem.oriDesc == insnNode.desc && asmItem.oriMethod == insnNode.name) {//方法名称和参数返回值一样
if (insnNode.opcode == asmItem.oriAccess && (asmItem.oriClass == "java/lang/Object" || insnNode.owner == asmItem.oriClass)) {
//处理init方法
if (asmItem.oriMethod == "<init>" && asmItem.oriAccess == Opcodes.INVOKESPECIAL) {
needReplaceInitNode.put(insnNode, asmItem)
} else {
insnNode.opcode = asmItem.targetAccess
insnNode.name = asmItem.targetMethod
insnNode.desc = asmItem.targetDesc
insnNode.owner = asmItem.targetClass
}
println "QHOOK ${insnNode.owner}.${insnNode.name}${insnNode.desc} @ ${klass.name}.${method.name}${method.desc}"
} else if (insnNode.opcode == Opcodes.INVOKESPECIAL && insnNode.owner != asmItem.oriClass && insnNode.name != "<init>") {
println "跳过相同方法名称和参数调用Hook,如需要hook请提供超级hook(oriClass = \"java.lang.Object.class\"),具体参考com.mqunar.goldcicada.lib.QActivity,\n${insnNode.opcode} ${insnNode.owner}.${insnNode.name}${insnNode.desc} @ ${klass.name}.${method.name}${method.desc}"
}
}
}
}
}
needReplaceInitNode.each { replaceNode, asmItem ->
//替换
replaceNode.opcode = asmItem.targetAccess
replaceNode.name = asmItem.targetMethod
replaceNode.desc = asmItem.targetDesc
replaceNode.owner = asmItem.targetClass
//向前查找需要remove的 NEW TypeInsnNode 和 DUP InsnNode
//查找逻辑是,如果replaceNode之前有相同init 相同owner desc不一样的数,有就跳过
def newNode = findTypeInsn(replaceNode, asmItem, 0)
//一定要先移除后边的,要不然当前的移除之后就成无主node了
method.instructions.remove(newNode.next)
method.instructions.remove(newNode)
//改变栈空间变化
method.maxStack = method.maxStack - 1
}
}
...
//向前查找newTypeInsn节点:参考 methodVisitor.visitTypeInsn(NEW, "okhttp3/OkHttpClient$Builder");
private static AbstractInsnNode findTypeInsn(AbstractInsnNode initNode, def asmItem, int skipCount) {
def preNode = initNode.previous
if (preNode.opcode == Opcodes.NEW && preNode.desc == asmItem.oriClass) {
if (skipCount > 0) {
skipCount--
} else {
return preNode
}
}
if (preNode.opcode == asmItem.oriAccess
&& preNode.owner == asmItem.oriClass
&& preNode.name == asmItem.oriMethod
&& preNode.desc != asmItem.oriDesc) {//如果找到一个不替换的,跳过一次
skipCount++
}
return findTypeInsn(preNode, asmItem, skipCount)
}

这样 new OkHttpClient.Builder() 编译后就被替换为 QOkHttpClient. getOkHttpClientBuilder() ,且不会造成方法内栈空间错乱。

细心的读者可能会发现 findTypeInsn 最后有一个参数 skipCount,这个什么作用呢?:stuck_out_tongue_winking_eye:,卖个关子,大家可以动手 hook 一个复杂的 new 嵌套试试,如:new File(new File("DirPath"),new File("childPath").getName());

6 写在最后

至此本项目已经实现对自身 app 及第三方 SDK 的调用监控,在需要 hook 一个方法调用时,用一个自定义方法、一行注解编译运行即可完成,而不需要修改源代码,升级第三方 SDK,脚本没有硬编码,也不需要做工具类版本判断。另外:如果想要 hook 第三方 SDK 反射调用,如运行时加载 dex 等功能,也可以用以上方法,hook 整个反射的调用过程,记录反射调用的类、方法、参数和返回值,从而保证整个客户端的安全。

END

VRbEJbU.png!mobile

BZFRriZ.jpg!mobile

IZfqYb7.jpg!mobile


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK