background blurbackground mobile blur

1/1/1970

Foony를 프록시 뒤에서도 돌아가게 만든 방법

Howdy! 웹 프록시는 예전부터 웹사이트랑 궁합이 잘 안 맞는다는 걸 알고 있었어요. 그런데 Foony의 프록시 지원은 특히 악명이 높았고, Foony가 프록시에서 잘 돌아가게 만드는 일은 꽤나 까다로웠어요.

이건 “Foony가 특이한 API를 써서 생긴 문제”도 아니에요 (물론 실제로 그렇긴 해요). 문제는 여러 가지가 한꺼번에 섞여 있었어요:

  • 프록시가 절대 손대면 안 되는 곳까지 문자열을 마구 재작성해 버리는 것.
  • 프록시가 메인 사이트 도메인과 “그 외” 도메인(CDN, 애셋 호스트 등)을 다르게 취급하는 것.
  • 그리고 어떤 프록시는 애초에 최신 웹 앱을 제대로 지원할 수 없다는 냉혹한 현실(HTTPS 정확성, WebSockets 등).

모든 프록시에서 돌아가는 건 아니지만, 목표였던 croxyproxyproxyorb에서는 이제 잘 동작해요.

아래에서는 뭐가 망가졌는지, 왜 망가졌는지, 그리고 진짜 효과 있었던 해결책이 뭔지 정리해 볼게요.


Pass 1: Valid, broken Three.js shaders

The symptom

croxyproxy로 접속해 봤을 때, 8 Ball Pool도, Foony의 다른 three.js 게임들도 전혀 플레이할 수가 없었어요. Three.js에서 셰이더 컴파일 오류만 계속 떨어졌고, 이런 식의 에러가 떴어요:

  • “Shader Error 1282 - VALIDATE_STATUS false”

이 메시지는 거의 아무짝에도 쓸모가 없어요. 보통은 “셰이더가 잘못됐네요, 알아서 해결하세요” 정도의 뜻이거든요. 참 친절하죠. 제가 왜 Foony에서 모든 에러마다 전부 다른 에러 메시지를 쓰려고 애쓰는지 궁금했다면, 바로 이 때문이에요. 이렇게 해야 '그냥 고장났으니까 알아서 고쳐'가 아니라, 문제 지점을 콕 집어서 찾을 수 있거든요.

그런데 애초에 셰이더는 멀쩡한 three.js 셰이더였거든요. 대체 왜 깨지는 걸까요?

The actual cause: proxies corrupting layout(location = N)

Three.js는 이런 식의 레이아웃 한정자(layout qualifier)가 들어간 GLSL을 생성해요:

layout(location = 0) in vec3 position;

어떤 프록시는 자바스크립트의 location API처럼 보이는 건 뭐든지, 전역 문자열 치환으로 억지로 바꾸려고 해요. JS에서만 그래도 이미 충분히 끔찍한데, 이걸 셰이더 소스 문자열 안에서도 똑같이 해버리는 거죠. 아마 AST 파싱 같은 건 비용이 많이 들어서 안 쓰는 모양이에요.

그래서 셰이더 소스가 이런 식으로 망가져 버렸어요:

layout(__cpLocation = 0) in vec3 position;

저기서는 식별자가 반드시 location이어야만 해요. 다른 어떤 이름이 들어가도 GLSL 문법상 잘못된 거라 컴파일러가 바로 거부해요. (GLSL의 Layout Qualifier 참고)

이건 Three.js가 셰이더를 동적으로 생성하고 런타임에 WebGL에 넘겨준다는 의미에서만 Three.js 문제예요. 진짜 버그는 프록시의 문자열 재작성 방식에 있죠.

Why I didn’t “fix the proxy”

가장 단순한 접근은 croxyproxy가 location을 바꿀 때 쓰는 치환 문자열 __cpLocation을 찾아서 다시 location으로 되돌리는 거예요. 하지만 프록시마다 쓰는 치환 이름이 다 달라요. 어떤 건 __cpLocation을 쓰고, 어떤 건 더 괴상한 식별자를 쓰죠. 그래서 '발견한 __cpLocation을 전부 location으로 바꾼다' 같은 하드코딩은 너무 취약해요.

제가 필요했던 건 이런 거였어요:

  • 특정 프록시 식별자에 의존하지 않는, 범용적인 해결책.
  • 프록시가 제 자바스크립트 안에 있는 location이라는 단어까지 모조리 바꾸더라도 통하는 해결책.

The base64 trick: hiding the word location from the proxy

프록시가 눈에 보이는 모든 location 문자열을 다 바꿔 버린다면, 가장 쉬운 방법은 그냥 location이라는 단어를 안 쓰면 돼요. 간단하죠. 예전에 Lua 코드를 뜯어 보면서 RestedXP의 가이드 보호 시스템을 역분석할 때 이런 트릭을 본 적이 있어요 (제 기억이 맞다면, 거기서는 BNGetInfo 호출을 _G("\x42\x4E\x47\x65\x74\x49\x6E\x66\x6F") 같은 식으로 난독화해 두었죠).

이 트릭은 자바스크립트에서도 똑같이 통합니다. client/index.html에서 런타임에 이렇게 디코딩해요:

// Because these proxies try to replace every `location`, we use a base64 encoded string.
const suffix = 'pb24=';
const locStr = atob('bG9jYXR' + suffix); // "location"
const loc = window[locStr]; // window.location

atob() 호출은 프록시가 HTML/JS를 몽땅 재작성한 다음에 실행되기 때문에, 프록시가 저 문자열을 미리 망가뜨릴 수가 없어요. 문자열을 둘로 쪼갠 건 더 눈치채기 어렵게 만들기 위한 거고, 굳이 'atob'를 쓴 것도 그냥 쓸 수 있으니까 쓴 거지, String.fromCharCodewindow['\x6c\x6f\x63\x61\x74\x69\x6f\x6e']처럼 헥스 이스케이프를 써도 비슷하게 먹힐 거예요.

깨진 셰이더의 패턴은 항상 구조가 같아요:

layout(<something> = <number>)

그래서 이 패턴을 포괄적으로 매칭해서 <something> 자리에 올바른 식별자를 넣도록 바꿨어요:

source.replace(/layout\s*\(\s*[^=)]+\s*=\s*(\d+)\s*\)/g, 'layout(' + locStr + ' = $1)');

The WebGL hook: patch shaderSource (WebGL1 + WebGL2)

Three.js는 내부에서 gl.shaderSource(shader, source)를 호출하니까, 아예 shaderSource 자체를 패치해 버렸어요:

const originalShaderSource = WebGLRenderingContext.prototype.shaderSource;

Object.defineProperty(WebGLRenderingContext.prototype, 'shaderSource', {
  value: function (shader, source) {
    return originalShaderSource.call(this, shader, fixCorruptedShaderSource(source));
  },
  writable: true,
  configurable: true,
});

그리고 WebGL2RenderingContext가 존재하면 거기에도 똑같이 패치를 넣어요.

이렇게 패치를 넣고 나니 셰이더 컴파일 에러가 싹 사라졌어요. 이 시점에서 croxyproxy에서는 잘 돌아가는데, proxyorb에서는 여전히 실패하더라고요. 왜죠?! 똑같이 동작해야 하는 거 아닌가요?


Pass 2: The second problem (domains) and why removing foony.io made everything easier

Foony는 예전부터, 적어도 지난 한 달 동안은 두 개의 도메인을 쓰고 있었어요:

  • 메인 사이트용 foony.com
  • 정적 애셋용 foony.io

처음에 이렇게 나눴던 이유는 꽤 실용적이었어요. 쿠키가 없는 도메인에서 애셋을 서빙하면, 정적 파일을 요청할 때마다 쿠키 헤더가 잔뜩 따라가는 걸 막을 수 있거든요. 이건 좋은 최적화고, HTTP/2가 헤더 바이트 수를 줄이려고 HPACK을 쓰긴 하지만, 그래도 생각보다 나쁘지 않은 이득이에요.

일반적인 브라우징 환경에서는 꽤 괜찮은 최적화죠.

하지만 프록시 뒤로 들어가면, 이게 아주 큰 장애 요인이 되어 버렸어요. 그리고 Foony 유저들은 프록시를 정말 좋아합니다. 하아...

Proxies treat “the main site” differently than “other sites”

많은 프록시는 “이 한 페이지/도메인만 프록시 해줘” 같은 용도로 최적화돼 있어요. 메인 HTML은 잘 불러오고, 자기들 스크립트를 주입하고, 자기들 ServiceWorker를 등록하는 것까지는 잘하죠.

그런데 앱이 다른 오리진(예를 들어 foony.io)에서 애셋을 가져오기 시작하면, 온갖 재미있는(?) 문제들이 터져 나와요:

  • ServiceWorker가 요청을 가로채다가 실패하는 경우, 예를 들면:
    • “ServiceWorker intercepted the request and encountered an unexpected error”
    • “Loading failed for the module with source”
  • 프록시 인프라가 특정 쿼리 파라미터를 꼭 필요로 하는데, 이게 살짝만 건드려도 쉽게 날아가 버리는 문제.
  • 애셋 요청에서 프록시 내부 라우팅 메타데이터가 사라져 버리는 문제.
  • 요청 전체를 generic-php-slug.php?someQueryParam=hugeEncodedString 같은 걸로 갈아 치워 버리는 괴상한 프록시들 (네, 그런 건 일부러 지원 안 합니다).

이런 프록시들의 내부 동작은 쿼리 파라미터나 URL 인코딩에 의존하고, 굉장히 쉽게 깨지곤 해요.

대표적인 예시가 이런 애셋 URL이었어요:

https://<proxy-ip>/assets/firebase-<hash>.js?__pot=aHR0cHM6Ly9mb29ueS5jb20

?__pot=... 부분이 바로 프록시가 쓰는 라우팅/상태 정보예요. 이게 있어야 프록시가 “이 요청은 어떤 도메인으로 보내야 하는지”를 알아요. 저걸 지워 버리면 프록시는 요청을 제대로 처리하지 못하고, 결국 ServiceWorker 에러로 떨어지게 되죠.

“Resource swapping” to the rescue (and why it got complicated fast)

한때는 이런 꼼수도 시도해 봤어요. '지금 프록시 뒤에 있다'를 감지하면, 모든 foony.io 리소스 URL을 현재 오리진으로 바꿔서, 프록시 눈에는 전부 동일 오리진 요청처럼 보이게 만드는 거죠.

듣기에는 꽤 그럴듯하고, 실제로 croxyproxy에서는 잘 먹혔어요. 하지만 곧 엄청난 복잡도를 끌어안게 됐죠:

  • 이미 HTML 안에 박혀 있는 linkscript 태그의 URL을 전부 갈아 끼워야 하고,
  • 동적으로 삽입되는 태그(modulepreload, 스타일시트 등)를 처리하려면 MutationObserver까지 붙여야 하고,
  • 프록시가 붙여 놓은 쿼리 파라미터는 꼭 보존해야 하는데, 이걸 조금만 잘못 건드려도 라우팅이 깨져 버려요. 게다가 프록시마다 이걸 처리하는 방식이 전부 다릅니다. 물론 그렇겠죠.
  • 거기에다 이 모든 로직을 프록시별 전역 변수에 기대지 않는 범용 코드로 짜야 해요. 안 그러면 코드베이스가 금방 프록시 전용 쓰레기장이 되어 버리거든요.

여기서도 “base64 꼼수”가 다시 등장해요. 제 자바스크립트 코드 안에서도, location이라는 문자열을 그대로 쓰면 프록시가 언제든지 바꿔 버릴 수 있어서 조심해야 했거든요.

Reverse-engineering CroxyProxy’s injected script

이쯤 되니 궁금해지더라고요. "이 프록시는 실제로 내 페이지에서 뭘 하고 있는 거지? 자기네 광고만 살짝 넣는 건가, 아니면 더 나쁜 걸 하고 있는 건가?"

CroxyProxy가 클라이언트 쪽에 주입하는 스크립트는 심하게 난독화돼 있어요.

(new Function(new TextDecoder('utf-8').decode(new Uint8Array((atob('NjY3NTZlN...')).match(/.{1,2}/g).map(b => parseInt(b, 16))))))();

실행해 보면 이런 코드가 나옵니다:

function a0_0x5ebf(_0x213dc9,_0x1c49b6){var _0x4aa7c1=a0_0x4274();return a0_0x5ebf=function(_0x159600,_0x51d898){_0x159600=...

이걸 보면 croxyproxy는 난독화를 위해 Obfuscator.io를 쓰는 것 같아요. 다행히도 webcrack으로 비교적 쉽게 디옵프스케이트(deobfuscate)할 수 있어요.

그러면 훨씬 읽기 좋은 자바스크립트가 나옵니다:

((_0x15ca2c, _0x489eaa) => {
  if (typeof module == "object" && module.exports) {
    module.exports = _0x489eaa(require("./punycode"), require("./IPv6"), require("./SecondLevelDomains"));
  } else if (typeof define == "function" && define.amd) {
    define(["./punycode", "./IPv6", "./SecondLevelDomains"], _0x489eaa);
  } else {
    _0x15ca2c.URI = _0x489eaa(_0x15ca2c.punycode, _0x15ca2c.IPv6, _0x15ca2c.SecondLevelDomains, _0x15ca2c);
  }
})(this, function (_0x724467, _0x275183, _0x219d84, _0x2dcc2c) {
  var _0x114c1e = _0x2dcc2c && _0x2dcc2c.URI;
  function _0x5a9187(_0x2fd4ea, _0x3bd460) {
    var _0x23a83f = arguments.length >= 1;
    if (!(this instanceof _0x5a9187)) {

좋네요. 이제 무슨 일을 하는지 대충 보입니다. 그리고... 생각보다 나쁘진 않아요. 난독화를 하는 주된 이유는 아마 "프록시가 쓰이고 있다는 사실"을 들키기 어렵게 만들기 위한 것 같아요. 대부분은요.

광고/UI를 주입하는 부분도 있어요:

    Bi(_0x308e2f) {
      console.log("Ads: " + _0x331b11.showAds);
      console.log("Ad codes: " + !!_0x331b11.adsJson);
      console.log("Adblock: " + _0x308e2f);
      _0x34fa18.document.body.insertAdjacentHTML("afterbegin", _0x331b11.modal);
      if (_0x331b11.header) {
        if (_0x331b11.header) {
          this.Ri(_0x308e2f);
        }
        [...document.querySelectorAll("#__cpsHeader a")].forEach(_0xcce595 => {
          _0xcce595.addEventListener("click", function (_0x3ef5f1) {
            if (this.target === "_blank") {
              _0x34fa18.open(this.href, "_blank").focus();
            } else {
              _0x34fa18.location.href = this.href;
            }
            _0x3ef5f1.stopImmediatePropagation();
            _0x3ef5f1.preventDefault();
          }, true);
        });
      }
      return this;
    }

이 밖에도 몇 군데 더 있지만, 요지는 광고를 띄우는 코드라는 거예요. 팝언더(pop-under) 스타일 광고도 있고요. 그리고 FuckAdBlock도 같이 쓰고 있어요.

하지만 핵심이 되는 문자열 치환은 서버 쪽에서 일어나고 있어요. 그리고 거기서 정확히 뭘 얼마나 하는지는 솔직히 아무도 모릅니다.

어쨌든 계정 보안을 조금이라도 신경 쓴다면, 웹 프록시는 웬만하면 쓰지 않는 게 좋아요. 정말 불가피하게 써야 한다면, 개인정보나 계정 정보, 결제 정보 같은 건 절대 입력하지 마세요.

"Resource swapping" in the bin

결국 저는 리소스 스와핑으로 생기는 복잡도와, foony.io 지원을 위해 코드 전반에 퍼져 있던 추가 복잡도를 모두 합쳐 봤을 때, "쿠키 없는 예쁜 요청"으로 얻는 작은 네트워크 이득은 전혀 그만한 가치가 없다고 판단했어요. 게다가 foony.io를 쓰기 시작한 뒤로 게임 플레이 전환율이 이유 없이 떨어지는 현상도 보였기 때문에, 우리가 모르는 다른 문제들도 foony.io 쪽에 더 있었을 거라고 의심하고 있어요.

그래서 foony.io는 없애 버렸습니다. 적어도 당분간은요.

foony.io CDN 관련 로직을 전부 지우고, 모든 걸 그냥 foony.com 하나로 통일하자 프록시 지원이 훨씬 단순해졌어요:

  • 애셋이 전부 동일 오리진에서 로드됨.
  • 프록시 ServiceWorker에게 설명해 줘야 할 "특수 케이스"가 훨씬 줄어듦.
  • 덜 억지스러운 URL 재작성.
  • 덜 깨지기 쉬운 코드.

요약하자면, foony.io를 없앤 건 구조를 단순화한 결정이었고, 프록시가 이상하게 굴 수 있는 여지를 크게 줄여 줬어요.


Pass 3: What works, what doesn’t, and why

Confirmed working proxies

지금 시점에서, Foony는 다음 프록시 뒤에서 잘 돌아가요:

  • croxyproxy
  • proxyorb

이 외에도 우연히 잘 돌아가는 프록시가 더 있을 수도 있어요. 그래도 대부분은 여전히 안 될 거라고 봅니다. 하지만 적어도 사람들이 게임을 하려고 많이 쓰는 주요 프록시들은 이제 대체로 잘 작동해요.

Why not “all proxies”?

어떤 프록시는 애초에 최신 멀티플레이어 웹 앱을 돌릴 수 있는 능력 자체가 없어요. 예를 들면:

  • HTTPS를 제대로 지원하지 못하는 프록시.
  • WebSockets를 깨뜨리거나 막아 버리는 프록시 (Foony는 실시간 네트워킹을 사용해요). 이론상은 우회할 수 있겠지만, 그러면 또 복잡도가 폭발하겠죠.
  • 크로스 오리진 요청, 헤더, ServiceWorker 같은 것에 제한이 너무 심한 프록시.

Key takeaways

Web proxies very insecure

웹 프록시는 이런 일을 하는 중간 계층(middleware)이에요:

  • HTML을 다시 써 넣고,
  • 자바스크립트를 다시 써 넣고,
  • 가끔은 ServiceWorker까지 몰래 집어넣고,
  • 라우팅을 위해 쿼리 파라미터나 URL 인코딩에 의존하고,
  • 여러분의 페이지를 온갖 방식으로 만지작거릴 수 있어요.

어떤 프록시는 얼마나 깊게 손을 대는지 보고 꽤 놀랐어요. 셰이더 소스 문자열, 주석까지 몽땅 다시 쓰고, 그 밖에 또 뭘 건드리는지는 상상도 안 됩니다.

Sometimes the best fix is architectural

WebGL 패치를 넣으니까 일단 게임은 다시 잘 렌더링되기 시작했어요. 하지만 멀티 도메인 CDN 전략을 없앤 덕분에, 프록시 지원이 지속적으로 안정적으로 유지될 수 있었죠.

이건 좋은 교훈이기도 해요. 똑똑한 최적화도, 중간에 끼어드는 프록시 같은 "적대적인 미들웨어"를 만나기 전까지는 완전히 합리적일 수 있어요. 아니면 유저의 브라우저 확장 기능이라든가, Safari라든가, 언어 설정, 접근성 기능, 태양 플레어, 뭐든지요.


Conclusion

이제 Foony는 중요한 프록시들(croxyproxy와 proxyorb) 뒤에서도 잘 동작하고, 코드베이스를 프록시 전용 쓰레기장으로 만들지도 않았어요:

  • 프록시별 식별자에 의존하지 않는, 범용 Three.js 셰이더 패치.
  • 더 단순해진 도메인 전략 (어디서나 foony.com 하나만 사용).
8 Ball Pool online multiplayer billiards icon