A small extension part II
UPDATE: (15/06/2024):
In less than half a day after I wrote this article Google decided to change something else and make the current decryption of the cipher not possible anymore, and not only that, they also made the regular streams with no signatureCipher to behave like those who do effectively locking down all the audio and video streams (until someone with more time than me figures out how the new cipher works). But fear not, when a door closes another opens so they say and in this case that door seems to be exlusively open only for first class citizens using an IOS device, sorry PC and Android users. Obviously we can disguise ourselves as an ipad or something and problem solved (the new code reflects that), for now.
But how long will this last? The cat and mouse game has actually begun. Original article below.
At the end of last year I made public a small extension I was coding that disables the video rendering (among other things!) when using Youtube’s web player allowing you to listen only to the audio stream saving both bandwidth and battery, you can read a little bit about that here to get more context for what I’m about to write now.
Some days ago I realized the extension wasn’t working on my computer but everything was smooth on my phone so I had to check what was going on, I’m not sure for how long it hasn’t been working since I’m not using my computer all that much but it couldn’t be more than ~10 days so I’m going to assume whatever happened started at the beginning of June.
In order to play only audio through the Youtube’s web player you need a URL with the audio-only stream, as far as I’m aware there are (were?) mainly only 2 ways of doing this:
- Looking for the network requests and then picking up the one with the correct audio stream.
- Get the configuration of the web player and retrieve from there all the information needed.
The former is more clean and elegant, once the URL is known it can be then sent to the player and that’s it, so simple right? This was also very easy because each URL had an iTag and/or mime parameter that made the identification of the stream straightforward. That doesn’t work anymore though, Google made some changes to the desktop’s web player used by Youtube, if I had to guess this is probably going to happen also for the mobile version at some point in the not so distant future, they really want you to see those ads I guess… Now without the possibility to find the stream looking at the network requests we are forced to use option 2, this is what yt-dlp and many others do, it’s not new, it’s well known and Google so far hasn’t done much to complicate things, but they could.
Once the player has loaded all kind of information can be extracted from it, to the purpose of this article the URL streams can be found inside an array properly called adaptiveFormats.stream.url or if they are “encrypted” they would be in adaptiveFormats.stream.signatureCipher, if the former not much to be done here other than filter the audio streams and choose the best to send to the player, business as usual just like before but if a signature cipher is used to validate the URL more work needs to be done. In order to make this post as short as possible and to understand what’s happening and what I’m about to write below I highly recommend reading this article.
Since this is a browser extension and it’s using manifest v3 we have some restrictions to what we can do compared to mv2 and other software but first a short explanation of what the signature is and does: it’s just a new parameter added to the regular stream URL that needs to be transformed in order to be validated by the web player, if we send the url without a valid signature it will fail with a 403 error.
The decoding process hasn’t changed in years, the signature needs to be passed N amount of times through either a splice, a swap or a reverse function, how do we know what to do and in what order? easy, there’s a base.js script loaded by the site that contains all this but this file changes randomly and during my testing I had to deal with 5 or 6 different versions in around ~48hs making a dynamic approach the only way forward.
With manifest v2 it could probably be possible to inject some inline script and do some magic to call the functions we need from base.js but that’s not possible with v3, 'unsafe-eval' is not available and we can’t create or own dynamic functions without triggering a huge CSP error, less malware I suppose. I tried to block the base.js script and load my own but that didn’t work so the version in youtube.com/iframe_api must match the base.js version, lets get to work then.
Luckily we know the cipher is only doing 3 things (reverse, swap or splice), as such we can clone a function that does just that with some slight modifications:
// our cloned version of the yt cipherSignature
var cipherTools = {
spl: function(a, b) {
a.splice(0, b)
},
rev: function(a) {
a.reverse()
},
swa: function(a, b) {
var c = a[0];
a[0] = a[b % a.length];
a[b % a.length] = c
}
};
Now we need to find out the N, and which of those 3 things does in each pass, for that lets split base.js at new lines, the function we are looking is always a complete line, we can find it using this:
if (l.indexOf('a.split("");') > -1) {
.slice(30).split('return')[0]
.split(";")
.lenght we can calculate the N and with some splice of our own we can get the parameters for the splice and swap functions (reverse doesn’t use any).
The last part is to figure out what is actually doing in each N pass, for that we need the aliases used by the base.js script and then compare them with each split we had previously done and if we find a match send it to our own cloned function with its corresponding parameter (even if reverse doesn’t need it we still have to send it). To get a unique match for each function I used the following with some slice(-2) at the end:
:function(a){a.reverse()
:function(a,b){var c=a[
:function(a,b){a.splice
Don’t forget to like, subscrib… whatever