How Numerade's paywall relied on publicly accessible S3 buckets
Numerade locked video answers behind a subscription paywall. The videos themselves lived in public AWS S3 buckets. A userscript, a few HEAD requests, and you had unrestricted access.
A friend asked me to get her Numerade videos without paying. Numerade is one of those homework help sites where someone records a walkthrough of a textbook problem and they charge you a subscription to watch it. Educational content behind a paywall. Not a fan.
I've always had a problem with this model in general. Charging for access to explanations of publicly available textbook problems feels wrong. The knowledge isn't proprietary. The textbooks are already paid for. Locking the explanation behind yet another subscription is just extracting money from students who are already broke.
So I poked around and found that Numerade's "protection" wasn't really protection at all. Their videos were sitting in public AWS S3 buckets with predictable URLs. No authentication, no signed URLs, just cdn.numerade.com/[path]/[videoId].[extension]. The paywall was purely cosmetic.
Finding the video ID
The video ID was already in the page. It had to be, since Numerade needed it to render the blurred preview and the poster image for the locked player. Three places to pull it from:
Inline scripts, where a videoUrl variable was often just sitting there:
function extractFromScripts() {
const scripts = document.getElementsByTagName('script');
for (const script of scripts) {
if (!script.src) {
const match = script.textContent.match(/videoUrl\s*=\s*['"](.+?)['"]/);
if (match) return match[1];
}
}
return null;
}
The Twitter card meta tag, which included a thumbnail URL with the video ID embedded:
function extractFromMetaTags() {
const metaElement = document.querySelector('meta[property="twitter:image"]');
if (metaElement) {
const content = metaElement.getAttribute('content');
const match = content.match(/\/([^/]+?)_large\.jpg$/);
return match ? match[1] : null;
}
return null;
}
Or the poster attribute on the video element itself:
function extractFromPoster() {
const videoElement = document.querySelector('video.vjs-tech');
if (videoElement) {
const poster = videoElement.getAttribute('poster');
if (poster) {
const match = poster.match(/\/([^/]+?)_[^/]+\.jpg$/);
return match ? match[1] : null;
}
}
return null;
}
Brute-forcing the CDN path
Videos were spread across five different paths on their CDN and encoded in three formats:
const baseUrls = [
'https://cdn.numerade.com/ask_previews/',
'https://cdn.numerade.com/project-universal/previews/',
'https://cdn.numerade.com/ask_video/',
'https://cdn.numerade.com/project-universal/encoded/',
'https://cdn.numerade.com/encoded/'
];
const fileTypes = ['webm', 'mp4', 'm4a'];
Fifteen possible URLs per video. Send a HEAD request to each, take the first one that returns 200:
function checkResourceExists(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'HEAD',
url: url,
onload: response => resolve(response.status === 200),
onerror: reject
});
});
}
Then replace the locked player with a plain <video> element pointing directly at the file. Remove the overlay, the registration modal, and all the Video.js UI elements that existed purely to block interaction. Done.
The actual problem
Numerade treated their frontend as the security layer. Blur the video, disable the controls, show a registration form. But the files on the CDN were publicly readable. No access control at all. The video IDs were in the page source, the bucket paths were guessable, and HEAD requests worked without any authentication.
This is a well-known AWS misconfiguration. S3 buckets are private by default, but someone at Numerade set theirs to allow public reads. The fix would have been straightforward, just serve videos through CloudFront with signed URLs that expire, so access requires a valid token from their backend. Instead, the only thing between a user and the video was the assumption that nobody would look at the network tab.
It doesn't work anymore
Numerade eventually fixed the bucket permissions and moved to signed URLs. The script stopped working at version 1.7 and I archived it. My friend got her videos though.
Code
The full script is archived here: github.com/GooglyBlox/free-numerade-videos