今天全场的消费由赵公子买单!

今天全场的消费由赵公子买单!

某日午后,吾有一友曰:“可有白嫖之法”

吾:“白嫖啥?”

友:“你懂的”

吾:“哦!那没有。”

友:“这个可以有!”

吾:“这个真没有!”

话说数日后,听闻某某电影(正经的)可以下载了,于是打开Mac到常去的电影下载网站找到了链接,想着用家中路由器上Docker里运行的qbit下载,摸鱼下班后,回家就能看了,突然发现内网穿透挂了。遂作罢。回家后我又想起来了,此时手边只有pad和手机,于是在手机上打开了下载网站,进行了下载,一切都刚刚好。但是在下载地址上放缺出现两个很X很暴力的GIF图,这是平时在Mac上访问不会出现的,于是想起前日吾友所言,点击进去后,好家伙,我直接好家伙,就直接开始下载APP了,没有一点点套路,让我猝不及防。

我还以你是个正经的电影下载网站,“没想到啊没想到,你这浓眉大眼的,也……”(–《主角与配角》)。

这不,我们的机会来了!安装后居然可以打开,但是你也知道的,大部分内容都需要VIP和金币购买。办他? 办他!不就是小小的VIP吗?办他,硬吗?够硬,硬不硬以后再说。我现在脑子里就只有一件事,白嫖!(–《让学》)

好的!废话不多说直接开始!(还不多?:point_up_2:t2:不全是废话),传统艺能第一步:砸壳

?砸什么壳?他都没上到App Store,你砸什么壳?哦!搞错了!

第一步:直接拿到IPA文件:

第一种方式打开下载页面然后Chrome 开发者模式,模拟iPhone

一般控制台就会出现 “itemsrvice://…”的信息只要截取里面的plist文件就能拿到下载地址。万万没想到他不按套路出牌,直接给我跳转到百度了!这我能忍?

能!算了,Chrome不行就换Safari,换响应式模拟iPhone依然不行!我看你是分明在为难我小猪佩奇!直接在模拟器里打开下载地址,可以访问了。好的,搞一个WKWebview加载一下下载地址,实在不行通过ULS注册NSURLProtocol拦截下载地址,不信就拿不到。进行到第一步的时候就发现在点击下载后地址直接跳转到了

itms-services://?action=download-manifest&url=https://plus.gxxtjt.com/plist/c0005.plist

这就直接拿到IPA文件了,

直接monkeyDev吧,省的一步步来,classdump 打开,跑一下试试。第一次运行成功打开也没什么异常,拿到 classdump 的头文件看了一下,大部分OC写的,那就easy了啊,现在能碰到这样的APP也是不容易了(后面几个一个比一个变态).

很容易就通过控制台日志找到了金币数量的关键字,在头文件里搜索了一下也找到了如下方法

#import <objc/NSObject.h>

@interface NSGetTools : NSObject
 + (long long)getJInBiNum;
+ (void)updateJinBiWithNum:(long long)arg1;
+ (long long)getPayMoney;
+ (void)updatePayMoneyWithMoney:(long long)arg1;

可以看出来,虽然只有2M的程序但充满了作者的智慧!居然可以同时使用jinbi和money,让我佩服的五体投地!然后直接给返回 99999 得了。再试一下!

直接闪退!出大事了!我就改了个金币,作者还加了防护?离谱,对不起是我错了,对作者的敬畏之心油然而生!我急忙把这几行代码注释掉再运行,依然闪退!

加上常用的exit(0)的断点试试,找到了一个block回调中调用了这个函数导致程序退出了,那是哪里呢?为什么第一次没有闪退?我猜测是在某个网络回调时候判断是否退出了,因为第一次打开APP没有网络访问权限需要先点击允许访问数据,然后检测的方法执行过了没有网络导致没有结果所以没有退出。试着关掉网络,测试果然不闪退了。证明还就哪个“成昆”!

接下来找到判断逻辑直接跳过就行了,那还是从exit(0)下手吧,直接设置个断点 br set -n "exit"

运行后进入断点查看调用堆栈

(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 2.3
  * frame #0: 0x000000018bf929b8 libsystem_c.dylib`exit
    frame #1: 0x0000000104f49910 abcd.dylib`__23-[timeLock timeControl]_block_invoke + 32
    frame #2: 0x0000000105091fc4 libdispatch.dylib`_dispatch_client_callout + 16
    frame #3: 0x0000000105094ca4 libdispatch.dylib`_dispatch_continuation_pop + 484
    frame #4: 0x00000001050a95b8 libdispatch.dylib`_dispatch_source_invoke + 2020
    frame #5: 0x00000001050a07f4 libdispatch.dylib`_dispatch_main_queue_drain + 744
    frame #6: 0x00000001050a04fc libdispatch.dylib`_dispatch_main_queue_callback_4CF + 40
    frame #7: 0x000000018188eab4 CoreFoundation`__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 12
    frame #8: 0x000000018184bfd8 CoreFoundation`__CFRunLoopRun + 2544
    frame #9: 0x000000018185ec30 CoreFoundation`CFRunLoopRunSpecific + 572
    frame #10: 0x00000001a2285988 GraphicsServices`GSEventRunModal + 160
    frame #11: 0x0000000184059c50 UIKitCore`-[UIApplication _run] + 1080
    frame #12: 0x0000000183df33d0 UIKitCore`UIApplicationMain + 336
    frame #13: 0x0000000104a5fdcc chengzi`___lldb_unnamed_symbol2194$$chengzi + 96
    frame #14: 0x0000000104cc43d0 dyld`start + 444

没啥好说的很明显了在这里 abcd.dylib`__23-[timeLock timeControl]_block_invoke

打开hopper 找到IPA里的abcd.dylib 拖进去,搞他,

/* @class timeLock */
-(void)timeControl {
    r31 = r31 - 0xb0;
    saved_fp = r29;
    stack[-8] = r30;
    var_8 = self;
    r0 = [var_8 message];
    r0 = [r0 retain];
    var_20 = r0;
    r0 = [r0 objectForKeyedSubscript:r2];
    r29 = &saved_fp;
    r0 = [r0 retain];
    [r0 release];
    [var_20 release];
    if (r0 == 0x0) {
            r0 = [var_8 message];
            r29 = r29;
            r0 = [r0 retain];
            [r0 setValue:0x5a788 forKey:@"message"];
            [r0 release];
    }
    r0 = [var_8 message];
    r0 = [r0 retain];
    var_38 = r0;
    r0 = [r0 objectForKeyedSubscript:@"ok"];
    r29 = r29;
    r0 = [r0 retain];
    var_2C = [r0 intValue];
    [r0 release];
    [var_38 release];
    if (r8 == 0x0) {
            var_48 = [[timeLock getAppBundleId] retain];
            r0 = @class(timeLock);
            r0 = [r0 getAppIdentifier];
            r0 = [r0 retain];
            stack[-176] = @"JN_uid";
            *(&stack[-176] + 0x8) = var_48;
            *(&stack[-176] + 0x10) = r0;
            var_58 = [[NSString stringWithFormat:@"%@%@%@"] retain];
            [KeyChainStore save:@"%@%@%@" data:@"a"];
            [var_58 release];
            [r0 release];
            [var_48 release];
    }
    else {
            dispatch_after(dispatch_time(0x0, 0x5f5e100), [objc_retainAutoreleaseReturnValue(*__dispatch_main_q) retain], 0x58d20);
            [r0 release];
    }
    return;
}

大概就是获取了程序的部分参数,后服务器上的参数对比不对应就exit了,发现只要修改 [timeLock getAppBundleId]的返回值就可以通过检测了,返回值就用IPA文件里的info.plist的bundleID 就O了。

下一步,图片区显示部分图片需要VIP 才能查看,通过Xcode视图层级看出图片加载出来了只是上面加了个覆盖的视图,想着从页面入手吧慢慢来!

那是不可能的!直接观察头文件,基本内容都在NSGetTools.hNSURLTools.h里这两个工具类一个负责了大部分的数据获取和管理还有一个负责网络请求发送和接收并解析还原加密数据。那就直接“MDConfig.plist”里安排上着两个类,直接打印所有方法调用(相当于生成%log)。

发现每次加载图片都会调用[<NSGetTools: 0x104fd54c8> getVIPGradesInfo]

直接hook掉返回 3,发现图片可以加载出来了,但是视频还要提示金币购买,

+ (long long)getJInBiNum{
    return 99999;
}
+ (long long)getPayMoney{
    return 99999;
}

这下有钱了吧,发现还是不能播放!

那我把视频改成不要钱的呢,通过网络接口找到视频的model是

%hook VideoModel
- (NSNumber *)jinbi{
    return 0;
}
%end

还是不行,最后通过播放的点击事件,找到VIP判断是+ (_Bool)selectIsVipWithVIPKey:(id)arg1 VIPEndTime:(id)arg2

直接hook掉 就可以播放了,but 福利视频依然提示需要金币。通过cell的点击事件找到如下代码

if ([NSGetTools getVIPGradesInfo] > 0x0) {
            r21 = [[NSString stringWithFormat:@"%@/api/useCoin.ashx"] retain];
            r0 = [r20 jinbi];
            r0 = [r0 retain];
            [r0 integerValue];
            r23 = [[NSGetTools getGongGongDictInfo] retain];
            r22 = [[r20 jinbi] retain];
            [r23 setValue:r22 forKey:@"coin"];
            var_70 = [r20 retain];
            [NSGetDataTool postDataWithUrl:r21 dictionary:r23 successBlock:&var_90 failurlBlock:0x100155620];
    }

大概意思,本地判断后还需要,到服务器请求一下,根据返回结果来执行两个block内容。

:ok_hand:!接下来看我操作就行了!直接hook方法 + (void)postDataWithUrl:(id)arg1 dictionary:(id)arg2 successBlock:(id)arg3 failurlBlock:(id)arg4 后断点调试一下

打印一下arg3 block 的签名信息

Block address: 0x16f01a110
Signature Address: 0x100f39610
Signature String: v16@?0@"NSDictionary"8
<NSMethodSignature: 0x8d3990fb658a311f>
    number of arguments = 2
    frame size = 224
    is special struct return? NO
    return value: -------- -------- -------- --------
        type encoding (v) 'v'
        flags {}
        modifiers {}
        frame {offset = 0, offset adjust = 0, size = 0, size adjust = 0}
        memory {offset = 0, size = 0}
    argument 0: -------- -------- -------- --------
        type encoding (@) '@?'
        flags {isObject, isBlock}
        modifiers {}
        frame {offset = 0, offset adjust = 0, size = 8, size adjust = 0}
        memory {offset = 0, size = 8}
    argument 1: -------- -------- -------- --------
        type encoding (@) '@"NSDictionary"'
        flags {isObject}
        modifiers {}
        frame {offset = 8, offset adjust = 0, size = 8, size adjust = 0}
        memory {offset = 0, size = 8}
            class 'NSDictionary'

这?猜也能猜到唯一的参数就是返回的JSON数据了。

判断一下请求的接口,如果是点击cell时调用的地址,直接就执行successblock 就完了,还要按返回的个格式组装一个dic作为参数,最终全部代码如下:

%hook NSGetTools

+ (long long)getJInBiNum{
    return 99999;
}

+ (long long)getPayMoney{
    return 99999;
}

+ (id)getUserIDInfo{
    NSNumber *str = @1;
    return str;
}
+ (long long)getVIPGradesInfo{
    long long res = %orig;
    res = 3;
    return res;
}
+ (_Bool)selectIsVipWithVIPKey:(id)arg1{
    return 1;
}

+ (_Bool)selectIsVipWithVIPKey:(id)arg1 VIPEndTime:(id)arg2{
    return 1;
}
%end

%hook timeLock

+(NSString *)getAppBundleId{
    return @"com.niao.xiaoxiao";
}
%end

%hook NSGetDataTool

+ (void)postDataWithUrl:(id)arg1 dictionary:(id)arg2 successBlock:(id)arg3 failurlBlock:(id)arg4{
    if ([arg1 containsString: @"/api/useCoin.ashx" ]){
        void(^successBlock)(NSDictionary*) = arg3;
        successBlock(@{@"code":@200,@"data":@"",@"message":@"success"});
    }else{
        %orig;
    }
}
%end

从此我的朋友开始了愉快的白嫖生涯。

你以为故事就这么结束了?

Of course not!

朋友又想白嫖直播盒子。

尝试各种渠道下载了几个直播盒子,发现他们不能说是极为相似,只能说是一模一样,什么“鲍某直播”,“百某直播”,“某某TV”。。。。。

“这是你吗?
这是我。那个时候我还很瘦。

你说他不是我? 不是!

我说他也不是我!这TM根本就不是我!” (–《让学》)

这些盒子全都是一个软件只是,用了不同的名字分发而已,并且,他们分发的方式也不再是企业签名,而是通过,用户下载描述文件,自动获取设备UDID,然后通过通过添加测试UDID进行adhoc签名,然后提示用户返回浏览器刷新页面即可下载,这个时候复制浏览器地址按照我们一开始的方法在Chrome里就可以得到下载地址。

下载IPA文件后打开,wtf?鬼故事发生了。这不是原生开发的,Flutter。抓包都抓不到,这还搞毛啊!放弃了?

你看看进度条也知道事情没这么简单,既然抓不到包,那就先想办法抓包吧,看看能不能从数据入手,页面入手基本是废了(就没什么原生的页面)。通过Google,找到一个方法,在手机上用shadowsock代理到Charles的端口可以抓包,但是抓到的内容都是加密的,单纯的内容加密,解密的算法是用dart写的不是原生的。更加丧心病狂的是图片也是加密的。例如xxxx.png下载后居然打不开,一看大小是正常的大小300多K,在APP的缓存文件夹里也是加密的图片。太狠了!

“还有王法吗?还有法律吗?”(–《功夫》)

略懂flutter的我知道,flutter和原生通信主要是建立隧道的方式,于是乎我想着截获这些隧道里发的数据看看有什么入手点

%group MTA
%hook FlutterMethodChannel

- (void)invokeMethod:(NSString*)method onMessage:(id)msg arguments:(id)arguments{
    %log;
    %orig;
}

- (void)invokeMethod:(NSString*)method arguments:(id )arguments{
    NSLog(@"======arg:%@",arguments);
    %log;
    %orig(method,[DKTest removeFee:arguments]);
}

- (void)invokeMethod:(NSString*)method arguments:(id )arguments result:(FlutterResult )callback{
    %log;
    %orig(arg1,[DKTest handleMethodCall:nil result:arg2]);
    %orig(method,[DKTest removeFee:arguments],callback);
}

- (void)setMethodCallHandler:(FlutterMethodCallHandler )handler{
    %log;
    %orig;//([DKTest mapFlutterHabdle:handler]);
}
%end
}

经过尝试我最终

放弃呢这条路。拦截这些隧道很容易触发崩溃,虽然拿到了一些网络请求的数据但是很不稳定,

这些盒子都是可以进行直播间预览的,10秒钟后才开始收费,这?有没有办法一直白嫖10秒钟?

要干就要干票大的,10秒钟算什么真男人。我们的目标就直接拿到rtmp地址想怎么看就怎么看。

“就你叫夏洛啊,你挺猖狂啊?也不打听打听我陈凯哥哥是什么人!

你们别过来啊,我跳过楼,我脑子可不好使。

说的好像谁脑子好使似的”(–《夏洛特烦恼》)

由于flutter生态还不是特别完善,播放器框架仍然使用的是其他原生播放器框架的flutter封装版本(SuperPlayer,ijkplayer),这直接给了我们入手的地方,通过查看程序包中的Frameworks文件夹发现这个直播平台使用的是腾讯的Superplayer,这个播放器是腾讯直播定制的,后来也在“基”站开源了,小弟曾经有幸使用过,但是发现这个flutter的版本进行了一些封装,并且这个fluter版本也可以在基站找到。

我们的目的是播放地址,通过和第一款APP一样的思路,给几个重要的类加了日志后发现了,每次播放调用的方法

%hook SuperPlayerKit

- (void)playUrl:(id)arg1{
    NSString * path = arg1;
    if([path length]){
        [DKTest.shared setRecentplayURL:path];
    }
    %orig;
}
%end

这里我直接用一个单例类全局保存最近一次直播间的传进来的地址,打印这个地址的确就是我们需要找的rtmp流。

为了省去打开其他APP粘贴地址的麻烦,我提供了两个方式

第一种直接增加一个全局按钮,点击时使用自己对SuperPlayer封装的视图控制器播放

 NSString *path = DKTest.shared.recentplayURL;
 if ([path length]){
   [DKTest.shared.playerKit stop];
   [PlayerViewController playWidthURL:path];
 }
 
 
#import "PlayerViewController.h"
#import <objc-runtime.h>
#import "DKTest.h"
@interface PlayerViewController ()
@property (strong) SuperPlayerView* playerView;
@end

@implementation PlayerViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    _playerView = [[objc_getClass("SuperPlayerView") alloc] init];
    _playerView.fatherView = self.view;
    _playerView.delegate = self;
    _playerView.isLockScreen = YES;
    _playerView.isFullScreen = NO;
    [_playerView.playerConfig setRenderMode:0];

    SPDefaultControlView *cv = (SPDefaultControlView *)_playerView.controlView;
    cv.disableDanmakuBtn = true;
    cv.backBtn.tag = 11111;

    _playerView.frame = self.view.frame;
    [_playerView setControlView:nil];
    [_playerView setHidden:NO];

    id playerModel = [[objc_getClass("SuperPlayerModel") alloc] init];
    [playerModel performSelectorOnMainThread:NSSelectorFromString(@"setVideoURL:") withObject:self.playURL waitUntilDone:NO];
    [_playerView performSelectorOnMainThread:NSSelectorFromString(@"playWithModel:") withObject:playerModel waitUntilDone:NO];
}

+(PlayerViewController *)playWidthURL:(NSString *)url{
    UIWindow * superWindow ;
    for (UIWindow *w in UIApplication.sharedApplication.windows) {
        if ([@"UIWindow" isEqualToString:[NSString stringWithUTF8String:object_getClassName(w)]]){
            superWindow = w;
        }
    }
    PlayerViewController *playerVC = [[PlayerViewController alloc] init];
    playerVC.modalPresentationStyle = UIModalPresentationFullScreen;
    playerVC.playURL = url;
    PlayerViewController __block __weak  * weakPlayer = playerVC;

    dispatch_after(1, dispatch_get_main_queue(), ^{
        [superWindow.rootViewController presentViewController:playerVC animated:YES completion:^{
            DKTest.shared.player  = weakPlayer;
        }];
    });
    return playerVC;
}


/// 返回事件
- (void)playerCloseAction:(UIButton *)sender{
    [self.playerView resetPlayer];
    [self dismissViewControllerAnimated:YES completion:nil];
}
- (void)dealloc
{
    [_playerView resetPlayer];
    NSLog(@"playercontroller --- deinit ---");
}
@end

                

这种方式点击时暂停当前直播间的播放并且present一个新的页面播放rtmp地址

第二种是自己编译了一个ijkplayer支持https和rtmp(为什么要自己编译一个呢?因为下一个APP时用的ijkplayer,并且内置的ijkplayer貌似被签名了),点击按钮时通过URL schemes将地址传递给自己做的播放器程序。

最后一款是一个仿bilibili(blibli是不是很像)的视频APP包含长短视频,也是flutter 做的,有了上一个APP的经验我们这次直接从播放器入手,发现然并卵,播放的地址时http://127.0.0.1/xx/xxxxx.m3u8,明显这些软件为了防止域名被监管封掉,使用了通过在本地开个服务器转发APP里的所有请求,拿不到转发的目的地址就白搭。有人可能呢想到了我们上面提到的shadowsock代理抓包,的确可以拿到请求的实际地址,但是这些服务器地址经常更换,我们也不能每次他一换我们就要抓包重新编译一遍啊。

省略了中间斗智斗勇的N天。

直接结论:我发现这个APP的数据是做了本地存储的,而flutter存储本地内容和IOS类似大都是借助原生的方式存储,这时候可以和我们的老朋友Userdefault玩耍了?当然不是,这款APP使用了腾讯(how 0ld are you?)的存储框架MMKV,有幸我也使用过。MMKV使用方式和Userdefault类似,于是游戏朝着解析数据的方向去了,

                MMKV *mmkv = [objc_getClass("MMKV") defaultMMKV];
                MMKV *mutiMMKV = [objc_getClass("MMKV") mmkvWithID:@"group.dkjone" mode:MMKVMultiProcess];
                [mmkv enumerateKeys:^(NSString *key, BOOL *stop) {
                        NSString *value = [mmkv getStringForKey:key];
                        NSLog(@"%@ = %@", key, value);
                    [mutiMMKV setString:value forKey:key];
                }];
                NSData *jsonData = [mmkv getDataForKey: @"mmkv.flutter._key_movie_recordfalse"];
                NSArray *arr = [NSJSONSerialization JSONObjectWithData:jsonData options:NSJSONReadingMutableLeaves error:nil];
                NSString * videoPath = [[[arr firstObject] valueForKey:@"mediaInfo"] valueForKey:@"videoUrl"];
                NSData *hostData = [[objc_getClass("MMKV") defaultMMKV] getDataForKey: @"mmkv.flutter._key_server_lines_false"];
                NSArray *hostArr = [NSJSONSerialization JSONObjectWithData:hostData options:NSJSONReadingMutableLeaves error:nil];

                NSString *playUrl = [NSString stringWithFormat:@"%@%@%@",hostArr[1],@"/api/app/media/m3u8/",videoPath];

是的如你所见上面的内容我么直接读取MMKV存储数据拿到了服务器列表(列表中的地址不一定全都有用,所以他们自己在APP里做的服务器会尝试多个服务器地址),经过测试一般第二三个可以访问所有内容,这里创建了一个APP group可以和group内的其他APP共享存储的内容,这就是上面提到的为什么自己做了个播放器,在播放器程序里可以直接播放了

// 将服务器地址存到 UserDefaults.apiHost ,然后根据服务器生成对应的播放地址       
collectionView.rx.itemSelected.bind {[unowned self] indexPath in
            var titles = UserDefaults.apiHost.map({ $0 + "/api/app/media/m3u8/" + self.sections[indexPath.section].items[indexPath.row].videoUrl})
            titles.append("取消")
            self.showAlert(title: "复制地址", message: nil, buttonTitles: titles) { idx in
                if idx != titles.count - 1 {
                    UIPasteboard.general.string = titles[idx]
                    let vc = VideoPlayerVC()
                    vc.indexPath = indexPath
                    vc.sections = self.sections
                    vc.host = titles[idx].replacingOccurrences(of: self.sections[indexPath.section].items[indexPath.row].videoUrl, with: "")
                    self.navigationController?.pushViewController(vc, completion: nil)
                }
            }
        }

播放地址时HTTPS的编译ijkplayer的时候需要加上TLS的相关选项,并且如果你要在monkeyDev里调试这个程序那你需要换掉程序原来的ijkplayer.framwork,否则会崩溃。播放地址都是m3u8格式的,如果你想收藏,你可以借助Downie 4 下载。

到此结束,你已经可以愉快的白嫖了,如果有用请把赵总大气发在评论区(狗头)。

至此我们发现灰产的技术更新的速度远远要比那些互联网大厂快的多,从OC 到 swif(还有一个是纯swift写的仿抖音的短视频平台略过了,有兴趣可交流)再到 flutter。但是re的技术似乎已经停滞不前了(我自己)。以上内容纯属娱乐。

【忘不掉的是什么我也不知道
放不下熟悉片段
回头望一眼 已经很多年的时间
透过手指间看着天
我又回到那老街
靠在你们身边渐行渐远。】

多年来,一直潜水,没发过贴(都是回复),至此第一次发帖,也是最后一次。

希望论坛转型成功,在此别过。

6 个赞

赵总大气

看完以后,即提高了技术,又增长了文化

要素过多,紫薯布丁

分析的很好,感谢分享。
调试过程思路清晰,但是遇到中文活韩文加密的类名后你会崩溃,估计很少人遇到过

学写了,很有料