Тащим музыку из ВК без публичного music API

в 7:00, , рубрики: api, javascript, информационная безопасность, Программирование, реверс-инжиниринг для новичков

Как все начиналось

Дело было вечером, делать было нечего… Точнее, я просто хотел загрузить аудиокнигу перед парами и тут меня ждал сюрприз. Кэш в кейт мобайле отключили. Как так? Что делать? Конечно же писать свое приложение с кэшем и аудиозаписями. Но для начала нужно понять, как вк превращает ссылки вида audio%user_id%_%track_id% в прямые ссылки на mp3. Что из этого вышло приложения я не написал и как скачать определенный плейлист можно прочитать под катом.

Дебажим js

Начинаем с очевидного — открываем вкладку аудиозаписей и смотрим код. Видим onclick на кнопке у каждой аудиозаписи:

onclick="return getAudioPlayer().toggleAudio(this, event)"

Открываем audioplayer.js, включаем Pretty print и ищем функцию toggleAudio().

toggleAudio()

AudioPlayer.prototype.toggleAudio = function(t, e) {
        if (vk && vk.widget && !vk.id && window.Widgets)
            return Widgets.oauth(),
            !1;
        if (domClosest("_audio_row__tt", e.target))
            return cancelEvent(e);
        var i = domClosest("_audio_row", t)
          , o = AudioUtils.getAudioFromEl(i, !0);
        if (window.getSelection && window.getSelection().rangeCount) {
            var a = window.getSelection().getRangeAt(0);
            if (a && a.startOffset != a.endOffset)
                return !1
        }
        if (e && hasClass(e.target, "mem_link"))
            return nav.go(attr(e.target, "href"), e, {
                navigateToUploader: !0
            }),
            cancelEvent(e);
        if (hasClass(e.target, "_audio_row__title_inner") && o.lyrics && !o.isInAttach)
            return AudioUtils.toggleAudioLyrics(i, o),
            cancelEvent(e);
        if (hasClass(e.target, "audio_row__performer"))
            return checkEvent(e) || vk.widget ? !0 : (AudioUtils.audioSearchPerformer(e.target, o.performer, e),
            cancelEvent(e));
        var s = cur.cancelClick || e && (hasClass(e.target, "audio_lyrics") || domClosest("_audio_duration_wrap", e.target) || domClosest("_audio_inline_player", e.target) || domClosest("audio_performer", e.target));
        if (cur._sliderMouseUpNowEl && cur._sliderMouseUpNowEl == geByClass1("audio_inline_player_progress", i) && (s = !0),
        delete cur.cancelClick,
        delete cur._sliderMouseUpNowEl,
        s)
            return !0;
        if (AudioUtils.isClaimedAudio(o) || o.isReplaceable) {
            var r = AudioUtils.getAudioExtra(o)
              , l = r.claim;
            if (l)
                return void (hasClass(i, "no_actions") || o.isInEditBox || showAudioClaimWarning(o, l, AudioUtils.replaceWithOriginal.bind(AudioUtils, i, o)))
        }
        if (o.isPlaying)
            this.pause();
        else {
            var n = AudioUtils.getContextPlaylist(i);
            this.play(o.fullId, n.playlist, o.context || n.context),
            cur.audioPage && cur.audioPage.onUserAction(o, n.playlist)
        }
        AudioUtils.onRowOver(i, !1, !0)
    }

Ставим брейкпоинт на первую инструкцию и нажимаем на кнопку. Входим в режим отладки и начинаем выполнять код по шагам.

Видим, что переменная o через несколько шагов содержит кучу полезной информации: исполнитель, название, хэши, всякие айди. Но не содержит главного свойства — url.
Идем дальше, выходим из toggleAudio(), попадаем в файл common.js в функцию, работающую с эвентами. Ага, значит ссылку мы получим асинхронно, надо бы посматривать на сетевую активность, что ж, шагаем дальше. Через n шагов замечаем, что во вкладке network появляется интересный запрос:

Request URL:https://vk.com/al_audio.php
Request Method:POST

Form Data:
act:reload_audio
al:1
ids: список id вида %user_id%_%track_id%, которые нужно обновить

И не менее интересный ответ:

4089188939145<!><!>0<!>6854<!>0<!><!json>[[456239119,%my_id%,"https://vk.com/mp3/audio_api_unavailable.mp3?extra=ofvLohaZvtDKnOPmEtHWl2rJCLLMrJiZy1i4D3blohn4AZLflLqZtMn3utbJmeTrCdq2Be1LyJ1HDvqTBKnUne8YrJfzzKrVENKXzL9JmMHgEgz3u3nbDwfuBMDiywLJBOrfl2fQwJDRmvrFzwDbwtGWvwThDxy6lxLHt1KOlvDODhbTAgjOzffOzdvTBOvOms9nvO9Ix1bxrNqUwgnfAfLWnwfLDLb0CLLxAvCVr2r4ExbHzhPTlKS2p3zcodu3yxm4Ea#CWS1mZi","Сон смешного человека ","Ф.М. Достоевский",4705,0,0,"",0,2,"","[]","6adb4186ee0c1d3ad0/5102a312745ae505a7/08eb0e4bd407e74e76/d313ec4b6051942649/","",[]],[456239118,%my_id%,"https://vk.com/mp3/audio_api_unavailable.mp3?extra=AdbkuuLOywjuou0Zme1yrdzJsI9Tm2qYvIO2AJaWnMnIztKOzgLxmMfbu3rgyJ8Tme5MBNrKlMLpChPIBLLFngjvu3zZAxztuOXJztfgrK9vq1rznLCYDJznBMrMCMT4rO9PytD3oc5kAhyYm1jMmZeZlxqZsKfWyKO9DNGUmvfuBtfOudjXrvbmyZLVltvICvPIver5zg5Zm2jWmJvWsue5nuXWzgPnBZq6l2n3x30OCgCVC2SVmxjqzvD6Egrlnc1zyq#CWSYmdC","автостопом в москву","црвених цветова",359,0,0,"",0,18,"","[]","57c59cffa93d47effa/836d457cff34e02fa0/6374a8e457c763a8c6/96db3ddc8c210b1fb0/","",[]],[456239117,%my_id%,"https://vk.com/mp3/audio_api_unavailable.mp3?extra=AgDYnY1TmwmOm2vTos5Ylu9Juwzkugv4zMzZyJbVlKjcqI9LB2vdzhe6DufVqY9KA1uZy3bfnhm4AgDOAwHIzMDztMTxsLjHn2K9veXuttvKq2fxogTWxY1fzJvumJrADLDlCdnLAv8UAfPiB2zxEKTrttLTrtK2ntrLCLnOww5rtI1Rq2vzsgr2vMnYp1P5BeOOx3rIEs8WBI10yLntCNzOCKrLBtznBMzRC29ewMOOC3uTueuYl29OztnWDhCXoffz#CWSYntC","Sal sér hon standa (Völuspá, 64-66)","Nytt Land",272,0,0,"",0,82,"","[]","4881448c55978a3374/40a707901f551a4572/778fe59467e6a629a9/02a308905303098496/","https://pp.userapi.com/c837628/v837628453/829d0/kLoB-0G_r78.jpg,https://pp.userapi.com/c837628/v837628453/829cf/C39pJ5b-t-w.jpg",80],[456239116,%my_id%,"https://vk.com/mp3/audio_api_unavailable.mp3?extra=l1yUmI1MCc8Vsxq2qxHYoM1Ku2i9C1y/EdzWzZfADhrpDgGYrwvJwwTRtZzHDwL3A2HZqKXXrOfHtZDVDKzZn1zIrKLJD3PVBKqOzxLylKDjvZuYqKf5qLG3rhn4nJnHs1rlt1PUy29mCvLuuxD6DJHJAtGXvNfjrMzkt29Wme93mKuWze55x1PotO01lMqZnuHfzJLgBM1Jluzqogvkr3KYs1fNmtn6qODYyx0XnxDZDNnvvO9Lsgn0tdrKDc9Kowm2#CWSOmJy","After Dark "," Mr.Kitty",351,0,0,"",0,18,"","[]","353d934506a14abed5/5745fb63e4e5f4abb4/2ed8c9b81df35317d7/01404a5db986cf16b8/","",[]],[456239115,%my_id%,"https://vk.com/mp3/audio_api_unavailable.mp3?extra=mJfWyY1SBMm/m1HYDc5YzgO5BwvdndC5nZDyvNzWyMvsEgfwyMvmsJzXm2fqEhq5mJvXvw50qIO4xZKTEefVsxq3yMfkltLpnJvWzfDrCZHgywiXEgzZqwntsJvMn21Nss9psKDkBKHKlY1LzI1vlJPyEuu3l3nyEhjsBNuWAhrlq3rezZfxmJn4A2zwtZbowhq3B1CWlZe4ytHZnhzhu19Lt29fvJDkCfLOzgvewI43rtjWmd1YBKLysOe2uMfKztbr#CWSZmJG","Storm","Godspeed You! Black Emperor",1352,0,0,"",0,34,"","[]","81b9d46c10d9df8d03/5299d303c627944df7/5ec696a0453d27253e/ce7a1e0600cff40c53/","",[]]]<!><!bool><!>7212f741260c90ab47

Понимаем, что это json, распаковываем

JSON одного из элементов

Отлично, теперь у нас есть хоть какая-то ссылка, но выглядит она не очень рабочей, что делать дальше?

Расшифровываем ссылку

Шагаем по скрипту дальше и понимаем, что ничего не выходит, на каком-то этапе ссылка просто превращается из audio_api_unavailable в ссылку на mp3. Значит где-то что-то происходит!
Но где именно? В одной из функций setUrl() вызывается функция с говорящим именем audioUnmaskSource()

Код функции
    function i() {
        return window.wbopen && ~(window.open + "").indexOf("wbopen")
    }
    function o(t) {
        if (!i() && ~t.indexOf("audio_api_unavailable")) {
            var e = t.split("?extra=")[1].split("#")
              , o = "" === e[1] ? "" : a(e[1]);
            if (e = a(e[0]),
            "string" != typeof o || !e)
                return t;
            o = o ? o.split(String.fromCharCode(9)) : [];
            for (var s, r, n = o.length; n--; ) {
                if (r = o[n].split(String.fromCharCode(11)),
                s = r.splice(0, 1, e)[0],
                !l[s])
                    return t;
                e = l[s].apply(null, r)
            }
            if (e && "http" === e.substr(0, 4))
                return e
        }
        return t
    }
    function a(t) {
        if (!t || t.length % 4 == 1)
            return !1;
        for (var e, i, o = 0, a = 0, s = ""; i = t.charAt(a++); )
            i = r.indexOf(i),
            ~i && (e = o % 4 ? 64 * e + i : i,
            o++ % 4) && (s += String.fromCharCode(255 & e >> (-2 * o & 6)));
        return s
    }
    function s(t, e) {
        var i = t.length
          , o = [];
        if (i) {
            var a = i;
            for (e = Math.abs(e); a--; )
                o[a] = (e += e * (a + i) / e) % i | 0
        }
        return o
    }
    Object.defineProperty(e, "__esModule", {
        value: !0
    }),
    e.audioUnmaskSource = o;
    var r = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN0PQRSTUVWXYZO123456789+/="
      , l = {
        v: function(t) {
            return t.split("").reverse().join("")
        },
        r: function(t, e) {
            t = t.split("");
            for (var i, o = r + r, a = t.length; a--; )
                i = o.indexOf(t[a]),
                ~i && (t[a] = o.substr(i - e, 1));
            return t.join("")
        },
        s: function(t, e) {
            var i = t.length;
            if (i) {
                var o = s(t, e)
                  , a = 0;
                for (t = t.split(""); ++a < i; )
                    t[a] = t.splice(o[i - 1 - a], 1, t[a])[0];
                t = t.join("")
            }
            return t
        },
        x: function(t, e) {
            var i = [];
            return e = e.charCodeAt(0),
            each(t.split(""), function(t, o) {
                i.push(String.fromCharCode(o.charCodeAt(0) ^ e))
            }),
            i.join("")
        }
    }
}

Ставим брейкпоинт на o(t) и смотрим, что приходит и что уходит. t — наша ссылка вида audio_api..., в ?extra= содержатся два параметра. Насколько я понял, один содержит зашифрованную ссылку, а второй — это что-то вроде ключа. Можно зареверсить алгоритм, понять, как именно он все это шифрует, а можно просто вызвать o('https://...audio_api_...'). Так я и решил сделать, и получил на выходе прямую ссылку на mp3

Получаем зашифрованные ссылки

Расшифровывать ссылки научились, метод для получения зашифрованной ссылки знаем. Как же нам теперь получить айди, которые нужно передать методу, возвращающему зашифрованные ссылки? Идем смотреть сетевую активность. Как мы поняли ранее, взаимодействие с audio API происходит через al_audio.php. Ставим фильтр на данный запрос, загружаем страницу с аудиозаписями заново и видим уже новый запрос

Request URL:https://vk.com/al_audio.php
Request Method:POST
Status Code:200
Form Data:
access_hash:
act:load_section
al:1
claim:0
offset:30
owner_id:my_id
playlist_id:-1
type:playlist

И в ответ получаем большой json в том же виде, что и раньше, который содержит данные о плейлисте. Поле offset отвечает за смещение, начиная с которого, мы будем получать данные о плейлисте. Кроме того, ответ содержит полезные данные: поля hasMore и nextOffset.

Собираем все вместе

Мы знаем как получить данные о плейлисте, как получить зашифрованные ссылки и как их расшифровать. Осталось собрать все воедино. Вот пример того, что получилось у меня. Проверил работоспособность на node.js v8.1.3.

Автор: saveliy_zhuravlev

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js