絶対に画像をダウンロード&スクレイピングさせないWebページを本気で作ってみた

絶対に画像をダウンロード&スクレイピングさせないWebページを本気で作ってみた

author potpro(ぼとぷろ)
2023/05/26

絶対に画像をダウンロード&スクレイピングさせないWebページを本気で作ってみた

巷で話題になっているこの話題、画像をスクレイピングやダウンロードされたくないということで騒がれています。その話に関しては色々な意見があると思ってますがここでは置いておくとして・・・

技術的にやるとしたら実際どれくらい対策できるの?ということが気になったので、自分の知識で出来る限り対策したものを作ってみることにしました。

最初に

賢い方はわかると思いますが、タイトルは釣りです。 絶対に画像をダウンロード&スクレイピングさせないページは存在しません。ソフトウェアにおいて絶対と言う言葉はまず存在しないのです。ブラウザで表示している以上、仕組みさえわかれば技術的には可能です。

そのため、 「元画像のダウンロードとスクレイピングを非常に困難にしたWebページを本気で作ってみた」 が実際のタイトルかなとなります。

とはいえ、この仕組みであれば大多数の人は機械的にスクレイピングすることを諦めるレベルの作りにはなっていると思います。自分だったら諦めるレベル。

とは言え一晩で考えた仕組みなので、これ以上に複雑で困難にする方法や逆に簡単に突破できる方法もあるかもしれません。後はChromeをベースにしているので他のブラウザであれば突破が楽だったりするかも。そういうものがあれば是非自分に教えてください。歓迎します。

出来たページ

まずできたページがこちらになります。アクセスして試してみてください。ちなみに対象の画像は一応自分が描いている絵です。

Twitterで画像を上げている方々と比べると全然上手い訳では無いですが、そこはご了承いただくと幸いです。

最強対策版

優しめ版

対策無し版

image-nan

最強対策版は後述する内容により本当に最強なので、通常利用のブラウザであれば何も出来ないと思います。ただ、これだと面白くない(&解析がやりづらい)ので優しめ版も用意しています。

優しめ版では右クリックと開発者ツールが使えるので、Chromeは右クリックから画像をダウンロードをすることは可能です。しかしこれはcanvasを画像に変換して取得しているので、スクリーンショット機能を使って取得しているのと実質的には変わりません。

この場合、canvasにはコピーライトが入っておりこれを取り除くことは出来ず、ダウンロードした画像にコピーライトが入っています。元画像では無いですね。

対策無し版は通常の場合です。右クリックからダウンロードすることは容易です。

自分が試した感じだと標準のブラウザだけでなんとか元画像を手に入れるのは非常に困難と思います。canvas上のデータを画像に変換してデータだけを抜き取ることは割と可能ですね(その場合コピーライトが付いてきます)。なのでダウンロードさせないに関しては完全に出来ないとは言えないかも。

ソースコードに関しては公開しています。かなりライブラリ依存しないシンプルなコードにしているので、わかりやすいかなと思います。全て見たい方はこちらより。

potproject/never-scrape-image

アプローチ

このページでのダウンロードとスクレイピングの困難にする技術は、このようなものを入れています。

HTMLImageElementを一切使わず、canvasで描画する

Webでは一般的に、imgタグを使って画像を表示します。Webで画像を表示する場合、ほとんどがそのような形を取っていると思いますが、今回imgタグは一切使用しません。

使用すると開発者ツールのNetworkのimgに表示されてしまうためです。

image

これを表示させないことで解析を困難にします。

代わりに、canvasタグを使用して画像を描画しています。canvasタグは画像とは異なり、特定の要素に画像や図形、文字を含めて表示することが可能です。コピーライトは文字をcanvas上に描画しています。

また、ネットを調べるとcanvasで画像を描画するためにnew ImageからHTMLImageElementをdrawImageで描画しているコードをよく見ますが、これも使用しません。これを使用してしまうと開発者ツールが検知して、Networkのimgタグに表示されてしまうからです。

代わりにcreateImageBitmapblobを使用して画像をcanvasに描画しています。この2つは画像として検知されないのでより困難にさせます。

開発しやすくさせるための開発者ツールを逆に使いづらくさせているという訳ですね。すごく不毛な感じがします。

canvasに描画するコードはこんな感じです。

    createImageBitmap(toBlob(dataURL)).then(bitmap => {
        canvas.width = bitmap.width;
        canvas.height = bitmap.height;
        const ctx = canvas.getContext('2d');
        ctx.drawImage(bitmap, 0, 0);
        // add copyright
        ctx.font = "22px MS Gothic";
        ctx.fillStyle = '#ff0000';
        ctx.fillText("© potproject.net", canvas.width - 200, canvas.height - 10);
    });

画像データを暗号化して送信し、ブラウザで復号する

先ほどのNetworkのimgに表示されてしまう、ネットワークパケットの解析により取得されてしまうので、画像ファイルそのままをサーバから送信する訳にはいきません。画像とは検出されないものを渡す必要があります。

この仕組みで一番考えやすいのは、AESを使用した暗号化です。AESは共通鍵を利用した暗号鍵方式です。自分も内部について詳しい訳では無いので詳しくは説明しませんが、Wifiやhttpsなどあらゆるものに使用されている標準的な暗号化方式であり、信頼性は非常に高い暗号化技術と思います。

仕組みとしてはAES256-CBC(PBKDF2-SHA256で鍵の生成)で暗号化した画像データをサーバから送信し、ブラウザで画像を復号します。

この仕組みを使うことで取得したデータはパスワード及びソルト(パスワードの推測をさらに困難にするためのランダムなデータ)がわからなければ画像を見ることができないようにします。ネットワーク上からは暗号化された画像ファイルデータを見ても何のファイルか何もわからない状態になります。

これらを実現する技術として、ブラウザおよびNode.jsには、Web Crypto APIというものが実装されています。これはブラウザ上で暗号化技術を使用することができるAPIです。これを使うことで上記のような暗号化と復号を行うことがが可能です。

Web Crypto APIを使用して画像ファイルを暗号化するサーバ側のコード(Node.js)はこんな感じです。


const fs = require('fs');
const crypto = require('crypto');

const password = "<略>";
const salt = "<略>";

async function main(){
    const file = await loadFile('plain.png');
    const base64 = Buffer.from(file).toString('base64');
    const encrypted = await aesEncrypt(base64, password, salt);
    await saveFile('encrypted.json', JSON.stringify(encrypted));
}

async function loadFile(path){
    return new Promise((resolve, reject) => {
        fs.readFile(path, (err, data) => {
            if(err){
                reject(err);
            }else{
                resolve(data);
            }
        });
    });
}

async function saveFile(path, data){
    return new Promise((resolve, reject) => {
        fs.writeFile(path, data, (err) => {
            if(err){
                reject(err);
            }else{
                resolve();
            }
        });
    });
}

async function aesEncrypt(data, password, salt){
    // Using Web Crypto API
    const { subtle } = crypto;
    const key = await subtle.importKey(
        'raw',
        new TextEncoder().encode(password),
        'PBKDF2',
        false,
        ['deriveKey']
    );
    const derivedKey = await subtle.deriveKey(
        {
            name: 'PBKDF2',
            salt: new TextEncoder().encode(salt),
            iterations: 100000,

            // SHA-256
            hash: 'SHA-256'
        },
        key,
        {
            name: 'AES-CBC',
            length: 256
        },
        false,
        ['encrypt', 'decrypt']
    );
    const iv = crypto.randomBytes(16);
    const encrypted = await subtle.encrypt(
        {
            name: 'AES-CBC',
            iv

        },
        derivedKey,
        new TextEncoder().encode(data)
    );
    return {
        iv: Buffer.from(iv).toString('base64'),
        encrypted: Buffer.from(encrypted).toString('base64')
    };
}

main();

このコードは、元々の画像ファイルであるplain.pngから暗号化してbase64形式にしたjsonであるencrypted.jsonというファイルを生成しています。base64形式にしたjsonファイルに変換しているのは、ブラウザで処理しやすい形式だからと言う感じです。

このjsonファイルだけで画像データを得ることは、暗号化されているため現時点ではまず不可能となります。(自分が書いたコードにセキュリティホールがあった、とかでない限りは)

この後、ブラウザ(クライアント)では暗号化したファイルを受け取った後、ファイルを復号しないと見ることができません。この処理を実装する必要があります。

実際のコード(Chromeで動作確認済)がこちらです。

const password = "<略>";
const salt = "<略>";

window.onload = async () => {
    const canvas = document.getElementById('canvas');
    const encrypted = await fetch('encrypted.json').then(res => res.json());
    const decrypted = await aesDecrypt(encrypted, password, salt);
    const dataURL = "data:image/png;base64," + decrypted;
    createImageBitmap(toBlob(dataURL)).then(bitmap => {
        canvas.width = bitmap.width;
        canvas.height = bitmap.height;
        const ctx = canvas.getContext('2d');
        ctx.drawImage(bitmap, 0, 0);
        // add copyright
        ctx.font = "22px MS Gothic";
        ctx.fillStyle = '#ff0000';
        ctx.fillText("© potproject.net", canvas.width - 200, canvas.height - 10);
    });
};

function toBlob(base64) {
    var bin = atob(base64.replace(/^.*,/, ''));
    var buffer = new Uint8Array(bin.length);
    for (var i = 0; i < bin.length; i++) {
        buffer[i] = bin.charCodeAt(i);
    }
    try{
        var blob = new Blob([buffer.buffer], {
            type: 'image/png'
        });
    }catch (e){
        return false;
    }
    return blob;
}

async function aesDecrypt(data, password, salt){
    // Using Web Crypto API
    const { subtle } = crypto;
    const key = await subtle.importKey(
        'raw',
        new TextEncoder().encode(password),
        'PBKDF2',
        false,
        ['deriveKey']
    );
    const derivedKey = await subtle.deriveKey(
        {
            name: 'PBKDF2',
            salt: new TextEncoder().encode(salt),
            iterations: 100000,

            // SHA-256
            hash: 'SHA-256'
        },
        key,
        {
            name: 'AES-CBC',
            length: 256
        },
        false,
        ['encrypt', 'decrypt']
    );
    const iv = atob(data.iv);
    const ivArrayBuffer = new Uint8Array(iv.length);
    for(let i = 0; i < iv.length; i++){
        ivArrayBuffer[i] = iv.charCodeAt(i);
    }

    const encrypted = atob(data.encrypted);
    const encryptedArrayBuffer = new Uint8Array(encrypted.length);
    for(let i = 0; i < encrypted.length; i++){
        encryptedArrayBuffer[i] = encrypted.charCodeAt(i);
    }

    const decrypted = await subtle.decrypt(
        {
            name: 'AES-CBC',
            iv: ivArrayBuffer
        },
        derivedKey,
        encryptedArrayBuffer
    );
    return new TextDecoder().decode(decrypted);
}

ブラウザではBufferが使用できないので、一部コードを変えています。

このコードは復号したファイルをBlobに変換し、canvasに描画するという感じです。これでおおむね画像部分は完成です。

JavaScriptコードの難読化と開発者ツールの無力化をする

共通鍵基盤を使用しているため、パスワードとソルトが分かれば復号が可能です。パスワードとソルトに関してはブラウザ側のJavaScriptコードにも書く必要が出てくるため、知られないようにすることを完全に防ぐことは不可能です。

この部分が100%防ぐことは無理と言う話と繋がってきます。しかし、限りなくコードを分かりづらくして、解析を困難にさせることは可能です。

ここで、JavaScriptコードの難読化を行います。難読化とは、挙動は同じように動作するコードですが、人間が見ても全く理解できないようなものに変換することを指します。

Javascriptの難読化には、JavaScript Obfuscatorと言うツールが存在します。このツールは難読化を行うツールですが、それ以外にも開発者ツールでのConsoleやDebugの無効化なども行うため、開発者ツールが使い物にならなくなります。正直これ入れるだけでもかなり効果があると思います。

Obfuscatorを使って変換されたコードを見てみましょう。目視では何一つわからないコードに変換されました。ここからコードを解析するのは至難の業でしょう。パスワードとソルトがどこにあるのかすら全く分からなかったです。

obfuscator.js

function _0x45aad3(_0x490d7a,_0x3f8e2d,_0x5689f8,_0x32785f,_0x1a158e){return _0x2697(_0x5689f8-0xa,_0x32785f);}...
...

obfuscator

これで、非常に強く解析を困難にさせます。ここまでやっていれば正直ほとんどの人が諦めるレベルでは無いでしょうか。

おまけで右クリック禁止にする

これはもうおまけですが、右クリック禁止にします。WindowsはF12ボタンで開発者ツールを表示できるし、ショートカットキーがあるのでそんなに意味は無いです。

が、解析を困難にさせるという観点だと無いよりはある方が良いくらいの感じではあります。ちょっと知識がある人ならほぼ意味は無いとは思いますね。

しかし、これを導入する事で利用者をイラッとさせてやる気を無くさせる効果はありそうです。実際右クリック禁止になってるサイトは自分もイラッとして使わなくなります。諸刃の剣ですね。

<body oncontextmenu="return false;">

方法はhtmlのbodyにこのように書くだけです。コードとしては簡単です。

突破する方法

ここまでやりましたが、自分が思いつくくらいの突破方法はやはり存在します。他にもあると思いますが、自分がさくっと考えたものはこんな感じ。

ヘッドレスブラウザを使用したスクレイピング

puppeteerなどのヘッドレスブラウザを利用して頑張る方法があります。Puppeteerはブラウザの挙動を解釈することが可能であり、独自のJavaScriptのコードを注入して、書き換えることもやろうと思えば可能です。これを使うことでスクレイピングの自動化は可能かなと思います。

これから考えられる対策としてはCAPTCHA認証などを使用して、機械的なアクセスを遮断すると完全では無いですがほぼ防げるのかなと思います。

復号するコードを書いて画像を取得する

パスワードとソルトが分かれば復号できるので、パスワードとソルトを見つけられれば結局防げません。考えられる対策としてはパスワードとソルトの一定期間ごとにランダムに変更して、難読化したクライアント側のjsを再生成するといいかもしれません。それでかなり厳しくなる気がします。難しくなるというより、難読化されたコードを解こうとした人の心が折れると思います。

画像自体を格納しているサーバを見つける

これはもうセキュリティホールのようなものですが、画像が格納されているサーバを見つけると攻撃者は手に入れることは可能ですね。そのため、実際は完全に元画像をインターネットに公開しないような仕組みにする必要があります。案外ここまでやってもこういうところに穴があって意味無かったりすることもあったりします。

[追記]Prototype汚染を用いた手法

自分で解いてみたい方は見ない方が良いかも

公開したおかげでたくさんの解法が寄せられました。喜ばしいことです。

その中でも一番クールだったのはPrototype汚染を使用した解法でした。確かに、これは強制的に特定のAPIを書き換えることができるため、コードが難読化されていて一切わからない、という前提でも推測すれば行けそうな感じがあります。

canvasを使用しているということDOMからわかってしまうので、画像を出力しているだろう、つまりdrawImageを乗っ取るようなコードを書ければ良さそうと言う推測が出来ます。

let canvas = document.getElementById('canvas');
canvas.getContext = () => {
    return {
        drawImage: (imgBitmap, x, y) => {
            const newCanvas = document.createElement('canvas');
            newCanvas.width = imgBitmap.width;
            newCanvas.height = imgBitmap.height;
            const ctx = newCanvas.getContext('2d');
            ctx.drawImage(imgBitmap, 0, 0);
            const a = document.createElement('a');
            a.href = newCanvas.toDataURL("image/png");
            a.download = "image.png";
            a.click();
        },
    };
}

このコードをchrome拡張などを使って最初に読み込むようにするとダウンロード出来ると思います。

しかもこれはどちらかと言うとJavaScriptの仕様を逆手に取ったような方法なので、対策するのも難しいと思います。

Canvasに描画する以上、Canvas APIを使わないで描画する方法は無いと思うので、対策するにはマイナーなAPIを使うとか、描画方法をわかりづらくする、わかりづらくするためにWASMを使う・・・とか考え着きますが・・・。

解析されたら処理を難しく変える、解析されたら処理を難しく変える・・・ということなのでつまりイタチごっこにしかならないということでもあります。

[追記]DRM技術

Amazon PrimeやNetflixで使われるDRM技術って使えないの?と言う話があったので実は書いていませんでしたが自分も調べてはいます。

基本的に動画ファイルのみ対応しているということで、静止画をそのまま動画に変換するということをやれば可能かもしれません。

しかしDRM技術を使うには復号用のライセンスをライセンスプロバイダーから購入する必要があって、とてもこんなお遊びレベルでDRMを加えるのは無理と言うことで対象外にしました。

ちなみに、調べた情報によるとDRMが入っている動画はスクショや録画が出来なくなるとされていますが、これはハードウェアレベルでの防止のため、ブラウザからハードウェアアクセラレーション機能を OFFにすると出来てしまう、などの回避方法はあるみたいです。

Why does disabling hardware acceleration in Google Chrome allow Discord users to stream sites like Netflix, TV streams, etc?

所感

ここまでいろいろと突破方法を書きましたが、自分の知らないツールで簡単に突破できたよ、みたいなこともあると思います。そうなるともう完敗ですね。

このような技術はイタチごっこに過ぎないかもしれません。

また、解析されづらいということはそれは開発する側であったとしても扱いづらいので、基本的にそういう技術は不快感の方が勝ってしまうこともあり素直にこうすれば解決できるとも言えないものかなと思います。

そこのバランスが難しいところですね。とはいえ今回色々とやってみると意外にいい感じになったかな?と思えるものが出来ました。

ちなみにこの元画像は自分が普通にTwitterやMastodonにアップロードしてるのでダウンロードは可能です。

しかし自分でもやっぱり無断利用はしてほしく無いとは思うので、結局のところ利用者側のリテラシーの問題なんだよな・・・ということで締めたいと思います。