Tainted canvas 이미지 캡쳐하기


요약

에러 화면 예시

오늘의 타겟

몇몇 웹사이트에서 tainted canvas 기능을 이용해서 다른 이름으로 이미지 저장 기능을 막아놓은 것을 발견했다. 하지만 tainted canvas는 제대로 된 DRM 기능은 아니기 때문에 이것을 우회해서 내맘대로 데이터를 읽어들이는 것이 가능하다. 이 글에서는 playwright로 브라우저를 조작해서 내가 원하는 canvas를 읽어들이는 방법에 대해 설명한다.

배경

MDN에 따르면 외부 도메인의 데이터를 가져다가 canvas를 그리는 경우, 페이지 제공 웹사이트가 원하지 않은 데이터가 canvas에 포함될 가능성이 생긴다. (이를테면 외부 도메인 데이터를 공격자가 조작해서 canvas에 넣을 수 있다는 소리)

브라우저에서는 보안 문제를 방지하기 위해 이런 경우 canvas를 tainted라고 간주하고, 해당 canvas에서 데이터를 읽어들이는 것을 방지하게 된다. 그리고 몇몇 웹사이트에서는 이걸 DRM을 구현하는데 이용한 것 같다…

가정

canvas를 javascript에서 그리고, 그 데이터는 Image 클래스를 이용해서 읽어들인다고 가정한다. 다른 방법으로 이미지를 읽어들이는 경우, 비슷하면서도 다른 접근이 필요해진다.

구현

브라우저 요청 단계에서 crossOrigin 허용하기

보통 다음과 같은 코드를 이용해서 이미지를 동적으로 로드하게 된다.

const img = new Image();
img.src = "https://example.com/image.png" // 여기서 example.com은 페이지 제공 웹사이트와 다른 도메인
img.onload = function() { /* 이미지 데이터를 우걱우걱 */ };

이렇게 이미지를 로드하는 경우 브라우저에서 example.com에 요청하는 이미지를 외부 도메인으로 인식, 이 image 데이터를 이용해서 canvas를 그리면 tainted로 간주하게 된다. 이를 피하기 위해서는 요청 시점에 “나 이 외부 도메인 데이터 믿습니다”라는 뜻으로 crossOrigin을 설정해줘야 한다. 참고자료 - MDN

그렇지만 우리가 직접 웹사이트 코드를 수정할 수 없는 경우이기 때문에, 여기서는 Image 클래스를 proxy하도록 브라우저를 조종해야 한다.

# %% Initialize playwright instances
p = await async_playwright().start()
browser = await p.chromium.launch(headless=False)
page1 = await browser.new_page()
await page1.add_init_script(script="""
(function() {
  let oldImage = Image;
  Image = new Proxy(oldImage, {
    construct(target, args) {
      const ret = new target(...args);
      ret.crossOrigin = "anonymous";
      return ret;
    }
  });
})();
""")

즉, Image 클래스를 Proxy함으로써, new Image()를 써서 이미지 변수를 생성할 때 잽싸게 crossOrigin을 설정해주는 것이다.

요청 반환 단계에서 access-control-allow-origin 헤더 삽입하기

이미지 제공 서버 또한 CORS에 맞게 이미지를 제공한다는 것을 설정해야 한다. 참고자료 하지만 우리는 이미지 제공 서버 또한 관리하지 않기 때문에, 해당 헤더 또한 중간에서 가로채서 삽입해줄 필요가 있다. 여기서는 playwright의 네트워크 조작 기능을 이용해서 해결할 수 있다.

# %% Add a route handler to add CORS header
async def routeHandler(route):
  response = await route.fetch()
  response.headers["access-control-allow-origin"] = "*"
  await route.fulfill(response = response)
await page1.route("**/*", routeHandler)

이와같이 하면 모든 외부 네트워크 요청에 대해 반환값에 CORS 헤더가 설정되게 된다.

결론

성공 화면

오케이

뚫었다. 근데 괜히 블로그 조회수 올리겠다고 공개했다가 금방 웹사이트들이 다른 방법으로 구현하면 어떡하나 걱정이 된다.

다른분들은 그냥 화면 스크린샷 찍어서 캡쳐하시는 게 어떨까요?