background blurbackground mobile blur

1/1/1970

Foony를 프록시 환경에서 작동하게 만든 방법

안녕하세요! 웹 프록시가 웹사이트의 호환성 문제를 일으킨다는 건 꽤 오래전부터 알고 있었어요. 하지만 Foony의 프록시 지원은 악명 높을 정도로 나빴고, Foony의 프록시 호환성을 해결하는 일은 꽤 까다로운 작업이었답니다.

이건 "Foony가 이상한 API를 쓴다"는 문제도 아니에요 (사실 그렇긴 하지만요). 문제는 여러 가지가 겹쳐 있었어요:

  • 절대로 건드리면 안 되는 곳에서 프록시가 공격적인 문자열 리라이팅을 하고 있었어요.
  • 프록시가 메인 사이트 도메인과 "다른" 도메인(CDN, 에셋 호스트 등)을 다르게 취급하고 있었어요.
  • 그리고 어떤 프록시들은 그냥 현대적인 웹 앱을 지원할 수 없다는 냉정한 현실(HTTPS 정확성, WebSocket 등)도 있었답니다.

모든 프록시와 호환되지는 않지만, 이제 적어도 croxyproxyproxyorb에서는 작동해요. 그게 목표였거든요.

아래에서 무엇이 망가졌고, 왜 망가졌는지, 그리고 실제로 효과가 있었던 수정 방법들을 설명해 드릴게요.


1차 시도: 유효하지만 망가진 Three.js 셰이더

증상

croxyproxy를 사용해 봤을 때, 8 Ball Pool이나 Foony의 다른 three.js 게임들을 플레이할 수 없었어요. Three.js에서 다음과 같은 셰이더 컴파일 오류가 계속 났답니다:

  • "Shader Error 1282 - VALIDATE_STATUS false"

이 메시지는 거의 아무 쓸모가 없었어요. 보통 "셰이더가 유효하지 않아요, 행운을 빌어요"라는 의미거든요. 훌륭하네요. 제가 Foony의 모든 오류마다 고유한 오류 메시지를 사용하는 이유가 궁금하셨다면, 바로 이게 그 이유랍니다. "코드가 망가졌으니 가서 고쳐"가 아니라 문제를 정확히 짚어내는 데 도움이 되거든요.

근데 왜 멀쩡한 three.js 셰이더가 깨지는 걸까요? 도대체 무슨 일이지?

진짜 원인: 프록시가 layout(location = N)을 손상시킴

Three.js는 다음과 같은 layout 한정자가 포함된 GLSL을 생성해요:

layout(location = 0) in vec3 position;

일부 프록시는 JavaScript의 location API처럼 보이는 모든 것을 단순한 전역 문자열 치환으로 다시 쓰려고 시도해요. 이건 JS에서도 이미 좋지 않은데, 셰이더 소스 문자열 안에서도 이 짓을 하고 있더라고요. AST 파싱은 그들에게 너무 비싼가 봐요.

그래서 셰이더 소스가 다음과 같이 망가졌어요:

layout(__cpLocation = 0) in vec3 position;

거기서 식별자는 반드시 location이어야 해요. 다른 건 모두 유효하지 않은 GLSL이고, 컴파일러가 거부하죠. (GLSL의 Layout 한정자)

이건 Three.js가 셰이더를 동적으로 생성하고 런타임에 WebGL로 전달한다는 점에서만 Three.js의 문제예요. 진짜 버그는 프록시의 리라이팅 전략이랍니다.

왜 "프록시를 고치지" 않았는가

순진한 접근법은 croxyproxy의 location 치환 문자열인 __cpLocation을 찾아서 location으로 다시 바꾸는 것이겠죠. 하지만 다른 프록시들은 다른 치환 이름을 사용해요. 어떤 건 __cpLocation을 쓰고, 어떤 건 다른 이상한 식별자를 쓰죠. 그래서 "__cpLocation을 다시 location으로 바꿔라"처럼 하드코딩된 수정은 취약해요.

저는 다음이 필요했어요:

  • 일반적인 수정 방법(프록시 식별자를 하드코딩하지 않음).
  • 프록시가 제 JavaScript에서 단어 location도 다시 쓰고 있더라도 작동하는 수정.

base64 트릭: 프록시로부터 location이라는 단어 숨기기

프록시가 보이는 모든 리터럴 location을 다시 쓴다면, 가장 간단한 방법은 그냥 location을 사용하지 않는 거예요. 충분히 쉽죠. 예전에 RestedXP의 가이드 보호 시스템을 리버스 엔지니어링할 때 Lua에서 이런 트릭을 본 적이 있어요(제 기억이 맞다면, 그들은 BNGetInfo의 사용을 난독화했어요. 예: _G("\x42\x4E\x47\x65\x74\x49\x6E\x66\x6F")).

물론 이 트릭은 JavaScript에서도 작동해요. client/index.html에서 다음을 런타임에 디코딩해요:

// 이 프록시들이 모든 `location`을 치환하려 하기 때문에, base64로 인코딩된 문자열을 사용합니다.
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']처럼 16진수 이스케이프도 작동할 수 있을 거예요.

망가진 셰이더 패턴은 항상 구조적으로 동일해요:

layout(<무언가> = <숫자>)

그래서 그걸 일반적으로 매치해서 <무언가>를 올바른 식별자로 치환해요:

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

WebGL 후킹: 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는 여전히 실패하고 있었어요. 왜?! 똑같이 작동해야 하는 거 아닌가요?


2차 시도: 두 번째 문제(도메인)와 foony.io를 제거하면서 모든 게 쉬워진 이유

Foony는 역사적으로, 적어도 지난 한 달 동안 두 개의 도메인을 사용했어요:

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

원래 이유는 실용적이었어요: 쿠키 없는 도메인에서 에셋을 제공하면 매 정적 파일 요청마다 쿠키 헤더 업로드 부담을 피할 수 있거든요. 이건 좋지만, HTTP/2가 HPACK을 사용해 헤더에 전송되는 바이트 수를 줄인다는 점을 고려하면 생각만큼 필요한 건 아니에요.

일반적인 브라우징에서는 유효한 최적화랍니다.

하지만 프록시 환경에서는 이게 주된 고장의 원인이 됐어요. 그리고 Foony 사용자층은 프록시를 사랑한답니다. 후우.

프록시는 "메인 사이트"와 "다른 사이트"를 다르게 취급해요

많은 프록시는 "이 페이지/도메인 하나만 프록시한다"에 최적화되어 있어요. 메인 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 오류 경로로 빠지게 되죠.

"리소스 스와핑"으로 구원 (그리고 왜 빠르게 복잡해졌는가)

한때 우회 방법을 시도했어요: "프록시되었다"는 것을 감지한 다음, 모든 foony.io 리소스 URL을 현재 출처로 바꿔서 프록시가 모든 것을 동일 출처로 보도록 만드는 거죠.

합리적으로 들리고, croxyproxy에서는 작동했지만, 많은 복잡성을 추가했어요:

  • HTML에 이미 존재하는 linkscript 태그를 교체해야 해요.
  • 동적으로 주입된 태그(modulepreload, stylesheet 등)를 처리하려면 MutationObserver가 필요해요.
  • 프록시의 쿼리 매개변수를 보존해야 해요. 안 그러면 라우팅이 깨져요. 그리고 프록시마다 이걸 다르게 처리해요. 당연히 그렇겠죠.
  • 그리고 코드가 부풀어 오른 쓰레기더미가 되지 않도록 로직을 일반적으로 유지해야 해요(프록시별 전역 변수 사용 안 함).

여기서 다시 "base64 트릭"이 등장했어요: 제 자신의 JavaScript에서도 프록시가 다시 쓸 수 있기 때문에 리터럴 문자열 location에 대해 조심해야 했답니다.

CroxyProxy 주입 스크립트 리버스 엔지니어링

이 시점에서 궁금해졌어요: 프록시가 실제로 제 페이지에 무엇을 하고 있는 걸까요? 자체 광고를 주입하고 있을까요? 더 나쁜 짓을 하고 있을까요?

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으로 충분히 쉽게 역난독화할 수 있답니다.

이렇게 하면 훨씬 읽기 쉬운 JavaScript가 나와요:

((_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;
    }

다른 부분도 있긴 하지만, 기본적으로 팝언더 스타일 광고를 포함한 광고를 보여주는 거예요. FuckAdBlock도 사용한답니다.

하지만 문자열의 진짜 치환은 서버 측에서 일어나고 있어요. 그리고 그게 전부 무얼 하는지는 아무도 몰라요.

어쨌든, 계정 보안을 신경 쓴다면 웹 프록시는 절대로 사용하면 안 돼요. 꼭 사용해야 한다면 PII/계정/구매 정보 입력은 피하세요.

"리소스 스와핑"은 쓰레기통으로

리소스 스와핑의 복잡성과 foony.io 지원을 위한 코드의 다른 부분의 복잡성을 종합해 봤을 때, 깔끔하고 쿠키 없는 요청의 작은 네트워크 절약은 그만한 가치가 없다고 결정했어요. 또한 foony.io를 채택한 이후 게임 플레이 전환율이 설명할 수 없이 떨어지는 것을 보고 있었기 때문에, 우리가 모르는 다른 foony.io 관련 문제가 있었던 게 아닌가 싶어요.

그래서 foony.io를 제거했어요. 적어도 지금은요.

foony.io CDN 로직을 삭제하고 모든 것을 foony.com으로 표준화하니, 프록시 지원이 극적으로 단순해졌어요:

  • 동일 출처 에셋 로드.
  • 프록시 ServiceWorker에 설명할 "특수 케이스" 감소.
  • 더 적은 리라이팅.
  • 덜 취약한 코드.

요컨대, foony.io를 제거한 것은 이상한 프록시 동작의 표면적을 줄인 아키텍처 단순화였답니다.


3차 시도: 무엇이 작동하고, 무엇이 작동하지 않는지, 그 이유

작동이 확인된 프록시

이 시점에서 Foony는 다음 환경에서 작동해요:

  • croxyproxy
  • proxyorb

다른 프록시들도 아마 작동할 거예요. 대부분은 여전히 작동하지 않을 거라고 장담해요. 하지만 적어도 사람들이 게임을 플레이하는 데 사용하는 중요한 것들은 작동하는 것 같아요.

왜 "모든 프록시"는 안 되나요?

어떤 프록시들은 그냥 현대적인 멀티플레이어 웹 앱을 지원할 수 없어요. 예를 들면:

  • HTTPS를 제대로 지원하지 않는 프록시.
  • WebSocket을 깨거나 차단하는 프록시(Foony는 실시간 네트워킹을 사용해요). 기술적으로는 우회할 수 있지만, 복잡성이 추가될 거예요.
  • 교차 출처 요청, 헤더, 또는 ServiceWorker에 너무 많은 제한을 두는 프록시.

핵심 요점

웹 프록시는 매우 안전하지 않아요

프록시는 미들웨어로서 다음과 같은 일을 해요:

  • HTML을 다시 써요
  • JavaScript를 다시 써요
  • 가끔 ServiceWorker를 주입해요
  • 그리고 종종 요청 라우팅을 위해 쿼리 매개변수/URL 인코딩에 의존해요
  • 여러 가지 방식으로 페이지를 만지작거릴 수 있어요

일부 프록시가 얼마나 깊이 들어가는지 보고 놀랐어요: 셰이더 소스 문자열, 주석, 그리고 신만이 아는 다른 것들까지 다시 쓴답니다.

때로는 최선의 해결책이 아키텍처에 있어요

WebGL 패치는 게임을 다시 렌더링되게 했지만, 다중 도메인 CDN 전략을 제거한 것이 프록시 지원을 지속적으로 안정적으로 유지하게 했어요.

좋은 교훈이에요: 영리한 최적화는 적대적인 미들웨어와 충돌하기 전까지는 완벽하게 합리적일 수 있어요. 또는 사용자의 브라우저 확장 프로그램. 또는 Safari. 또는 언어 설정. 또는 접근성 기능. 또는 태양 플레어. 또는 뭐든지요, 정말로요.


결론

Foony는 이제 코드베이스를 프록시별 난장판으로 만들지 않으면서도 중요한 프록시들(croxyproxy와 proxyorb)에서 작동해요:

  • 일반적인 Three.js 셰이더 수정(프록시별 식별자 없음).
  • 더 단순한 도메인 전략(어디서나 foony.com).
8 Ball Pool online multiplayer billiards icon