•
8 min read

Biến CDN của TikTok thành kho phim miễn phí

cover
Table of Contents

[!] Exploit trong bài đã bị TikTok fix [!] Bài viết chỉ nhằm mục đích nghiên cứu và chia sẻ kiến thức. Việc lạm dụng API của bên thứ ba đi ngược lại điều khoản sử dụng của họ và có thể phải chịu trách nhiệm pháp lý. Mình không khuyến khích ai làm theo.

Chuyện bắt đầu từ một thread khá nổi tiếng trên VOZ: “Các web phim đã giảm chi phí bằng TikTok như thế nào?”. Tóm tắt cho ai lười bấm link: các web phim lậu ở VN đã tìm ra cách “mượn” của TikTok để lưu trữ và phân phối video, chi phí đúng bằng 0 đồng 🍃

Vì sao lại là CDN của TikTok?

Web phim là một trong những mô hình ngốn băng thông kinh khủng nhất trên internet. Một tập phim 1080p nặng ~1.5GB, một người xem là hết 1.5GB băng thông, mà 1000 người xem cùng lúc thì bạn tự tính nhé 😅 Đây là lý do các web phim hoặc là treo đầy quảng cáo, hoặc chết vì hoá đơn VPS.

Thread trên VOZ nhắc đến các hostname thuộc hạ tầng quảng cáo của TikTok. Nhìn tên miền có thể đoán đây là nội dung được phục vụ qua CDN, nhưng chỉ riêng hostname thì không đủ để biết chính xác kiến trúc, vị trí node hay cam kết hiệu năng phía sau.

https://p21-ad-sg.ibyteimg.com/obj/ad-site-i18n-sg/...
https://p16-ad-sg.tiktokcdn.com/origin/ad-site-i18n-sg/...

Với quảng cáo, việc ảnh được phân phối qua URL công khai là bình thường: trình duyệt của người xem cần tải được banner mà không phải đăng nhập vào tài khoản của advertiser. Nhưng một URL công khai không đồng nghĩa dịch vụ đó là kho lưu trữ miễn phí cho mọi loại file.

Nói cách khác, các web phim này đang “gửi nhờ” kho video của họ trên hạ tầng phân phối quảng cáo toàn cầu của TikTok. Hạ tầng mà ByteDance đã đổ hàng triệu đô để xây dựng: edge server khắp châu Á, băng thông gần như vô hạn, và latency thấp đến mức TikTok chạy mượt trên 3G.

Thiết nghĩ đây là gói CDN mà Cloudflare phải bán giá cả chục nghìn đô mỗi tháng 🤣

Tìm cửa upload

Nếu bạn từng chạy quảng cáo TikTok, bạn sẽ biết advertiser cần upload “material” (hình ảnh, video) cho chiến dịch. Việc upload này đi qua một API nội bộ của trang quản trị:

POST https://business.tiktok.com/api/v3/bm/material/image/upload/?org_id={org_id}

Thread VOZ hồi đó dùng bản cũ hơn là ads.tiktok.com/api/v2/i18n/material/image/upload/

Điều kiện để gọi được cái endpoint này cần một tài khoản TikTok Business tạo miễn phí (lấy org_id), cookie đăng nhập và x-csrftoken. Không cần nạp đồng nào.

Upload xong server trả về:

{
    "code": 0,
    "data": {
        "image_info": {
            "web_uri": "ad-site-i18n-sg/202509235d0d4b937507-fdac-4851-8...",
            ...
        }
    }
}

Ghép web_uri với domain CDN là có link tải công khai, không cần auth, không check referer:

https://p16-ad-sg.tiktokcdn.com/origin/{web_uri}

Nhưng mà khoan, upload được “ảnh” thì liên quan gì đến phim?

Ảnh 1x1 nặng 2.8MB

Endpoint này chỉ nhận ảnh. Nhưng nó xác định file có phải ảnh hay không bằng cách đọc magic bytes ở đầu file, tức là chỉ nhìn vào vài byte đầu tiên chứ không kiểm tra toàn bộ nội dung.

Vì thế: nếu phần đầu file là PNG hợp lệ, thì phần còn lại của file là gì server cũng mặc kệ.

PNG được cấu tạo từ các chunk. Theo đặc tả PNG của W3C, IEND đánh dấu phần kết thúc của datastream PNG. Trình xem ảnh thường dừng ở đó, nên dữ liệu phía sau không xuất hiện trong hình được render.

MPEG-TS thì có cấu trúc packet lặp lại; mỗi packet dài 188 byte theo tài liệu ITU-T về MPEG-2. Một trình đọc ảnh và một bộ giải mã video không nhất thiết diễn giải cùng một file theo cùng cách.

Vậy thì “steganography” ở đây thực ra rất đơn giản:

┌──────────────────────┬─────────────────────────────────┐
│  PNG 1x1 — 69 bytes  │   MPEG-TS segment — vài trăm KB │
└──────────────────────┴─────────────────────────────────┘
   ↑ server nhìn vào đây        ↑ player đọc từ đây

Tạo một PNG 1×1 pixel bằng Pillow, rồi append thẳng dữ liệu .ts vào cuối file. Xong. Cái file giờ nhìn phía trước là ảnh hợp lệ (vẫn mở được bằng image viewer), nhìn phía sau là một segment video hoàn chỉnh.

Một file như thế này được gọi là polyglot file: hợp lệ theo nhiều định dạng cùng lúc, tùy bạn đứng ở đâu mà nhìn thấy “ảnh” hay “video”.

Pipeline hoàn chỉnh

Được các mảnh ghép rồi, giờ lắp vào nhau thành dây chuyền.

┌────────────┐   ┌─────────────┐   ┌──────────────┐   ┌─────────────┐   ┌──────────────┐
│ Video gốc  │──▶│ FFmpeg cut  │──▶│ PNG 1×1 +    │──▶│ Upload API  │──▶│ Dựng lại     │
│ .mp4       │   │ HLS .ts     │   │ append .ts   │   │ TikTok BM   │   │ playlist     │
└────────────┘   └─────────────┘   └──────────────┘   └─────────────┘   └──────────────┘
                                       "stego"                               .m3u8 → CDN URL

Phát thì sao?

Có link CDN rồi nhưng mà đưa thẳng vào player thì chưa xong, vì phía playback có vài vấn đề:

  1. CORS: hls.js fetch segment bằng XHR, nên CDN phải trả header access-control-allow-origin hợp lệ thì trình duyệt mới chịu
  2. Che nguồn: nếu để playlist gốc trỏ thẳng TikTok CDN, ai cũng có thể view-source lấy link mà dùng chùa tiếp
  3. Vấn đề vòng đời: link ads material có thể bị dọn dẹp bất cứ lúc nào, cần có nơi để cache/replace linh hoạt

Cách các web phim xử lý là đút mọi thứ qua proxy của chính họ. Mình làm một POC web phim hoàn chỉnh (hls-streaming-poc) để thử nghiệm góc này. Playlist được serve qua một API route, từng dòng URL segment bị rewrite on-the-fly:

// /api/hls - đọc playlist, rewrite URL TikTok CDN qua proxy
const rewrittenLines = lines.map(line => {
    if (line.trim() && !line.startsWith('#') && line.includes('tiktokcdn.com')) {
        const proxyUrl = `/api/hls-segment?url=${encodeURIComponent(line.trim())}`;
        return proxyUrl;
    }
    return line;
});

Còn /api/hls-segment là cổng ra CDN thật, kèm thêm lớp bảo vệ chống hotlink: file path được obfuscate (double base64 + reverse), kèm token có thời hạn 5 phút và check referer bằng base64:

if (obfuscatedFile && token && sessionId && timestamp && ref) {
    const decodedRef = atob(ref);
    if (!requestHost.includes(decodedRef) && decodedRef !== 'localhost') {
        return NextResponse.json({ error: 'Invalid referrer' }, { status: 403 });
    }
    if (Date.now() - parseInt(timestamp) > 5 * 60 * 1000) {
        return NextResponse.json({ error: 'Request expired' }, { status: 403 });
    }
    // decode: base64 -> reverse -> base64
    const decoded = atob(obfuscatedFile);
    ...
}

TikTok sẽ fix thế nào?

Đặt mình vào vị trí đội bảo mật ByteDance thì lỗ hổng không nằm ở upload endpoint (nó đúng chủ đích mà), mà ở validation chỉ nhìn vỏ bọc. Muốn vá thì:

  • Check kích thước file so với kích thước ảnh thực tế. Một “ảnh 1x1” nặng 2.8MB thì không cần ML gì cũng biết là có vấn đề.
  • Tìm vị trí IEND/EOI, từ chối nếu còn dữ liệu phía sau.
  • Re-encode mọi ảnh khi upload (Thật ra panel UI của Ads Manager đã nén ảnh rồi, chỉ có đường API trực tiếp là bỏ qua bước này)
  • Quét storage định kỳ. Mà nói thật, không cần quét từng file, chỉ cần nhìn một acc upload hàng nghìn “ảnh” mỗi ngày, mỗi ảnh nặng vài MB là hiểu =))

Còn về phía các web phim, đây là mô hình single point of failure kiểu khác: sống nhờ một API mà mình không kiểm soát. TikTok đổi endpoint, thêm captcha, hoặc đơn giản xoá dọn storage thì cả thư viện phim bay màu trong một đêm.

Kết

Trong vụ này không có bug nào cả. Chỉ là một tính năng hợp pháp nhưng được các pháp sư Việt Nam dùng cho mục đích khác 🤣