NOMO.asia

이 글은 UserScript 환경에서 JavaScript 를 이용해 이벤트의 동작을 수정 및 삭제(hijacking) 하는 과정을 정리한 글이다. 꼭 UserScript 뿐만이 아니라 웹사이트의 동작을 바꾸는 브라우저 확장기능을 JavaScript 기반으로 개발한다면 모두 사용할 수 있는 방법이다.

예제

어떤 웹 채팅 시스템이 있다. 이 채팅 시스템은 다른 사용자가 올린 링크를 클릭할 때 아래와 같은 확인 창을 띄운다.

문제는 이것이 링크의 안전성 여부를 직접 확인해주는 것도 아니고, 최초 1회만 물어보는 것도 아니면서 매번 링크를 클릭할 때마다 단순히 물어보기만 하기 떄문에 결국 사용자를 귀찮게만 만드는 쓸모없는 절차라는 점이다.

그래서 나는 브라우저 확장 기능의 일종인 UserScript 로 이러한 동작, 정확히는 click 이벤트를 없애자고 마음먹었다.

기존 코드 구조 분석

HTML 구조

실제 채팅창에는 채팅 표시부, 채팅 인원수 표시부, 각종 버튼, 채팅 입력란 등 다양한 요소가 많이 있다. 이 중 설명에 필요 없는 부분들을 생략하고 채팅창의 HTML 구조를 간략화하여 나타내면 아래와 같다.

<div id="chat_cont">
    <ul id="msg_cont">
    </ul>
</div>

처음에 채팅 스크립트가 로드되면 동적으로 위 HTML 구조에 해당하는 요소를 생성하여 정해진 채팅창 자리에 삽입한다.

chat_cont 는 채팅창 전체를 감싸는 container 이고, msg_cont 는 각 채팅 메시지를 감싸는 container 이다. 채팅 내용을 받아오면 msg_cont 안에 동적으로 <li class="content">${content}</li> 같은 형태의 요소를 생성한다. 채팅 내용에 해당하는 ${content}는 a 태그를 포함하고 있을 수 있다.

JavaScript 코드

채팅 내 링크를 클릭하면 메시지를 띄워 이동 여부를 확인하는 코드는 다음과 같이 되어있다.

(function(){
    //
    // HTML을 동적으로 삽입하는 코드는 이곳에 삽입됨 (생략)
    //

    // 클릭 이벤트
    $("#msg_cont").on("click", "a", function(e) {
        confirm("[주의] 안전한 링크인지 확인하세요. 피싱/파밍/바이러스를 조심하세요.
        \n해당 사이트 관리자에게 IP 주소, OS, 브라우저, 현재 사이트의 주소 등이 노출될 수 있습니다. 계속하시겠습니까?")
         || e.preventDefault()
    });
}();

confirm 함수로 확인 창을 띄우고, 취소에 해당하는 값이 들어올 경우 e.preventDefault() 을 실행하여 a 태그의 기본 동작을 막는 구조이다.

즉시 실행 함수 표현(IIFE, Immediately Invoked Function Expression)으로 작성되어 있어서 위 코드는 스크립트가 로드되지마자 바로 실행되도록 되어있다. 따라서 window 객체를 통해 위 함수 내에 접근하는 것은 불가능하다. 또한 click 이벤트에 리스너로 등록된 함수가 익명 함수라서, 함수를 덮어씌워 동작을 수정하거나 이벤트를 지우는 것 역시 불가능하다.

저 click 이벤트를 아예 없애거나(unbind, remove) 어떻게든 무력화하는 것이 목표이다.

실패한 시도들

  1. removeEventListener 함수를 이용해 이벤트를 지우려면, 리스너를 참조할 수 있어야 하는데, 위 예는 익명함수이므로 리스너를 참조할 수 없어 불가능하다.
  2. 클릭 이벤트가 jQuery 를 이용하여 선언되었으므로, $("#msg_cont").off("click"); 등 off 함수를 이용한 시도를 해봤으나 안 먹혔다.
  3. 내가 지우길 원하는 이벤트가 할당되기 전, $(document).on("click", "#msg_cont > a", function(e){e.stopImmediatePropagation()}); 와 같이 이벤트를 만들어서 이벤트 전파를 막으려고 해보았으나 안 됐다. 이벤트는 등록한 순서대로 실행되므로, 내가 더 빨리 이벤트를 등록하여 a 태그 클릭 시 내가 원하는 코드가 먼저 실행되도록 한 다음 e.stopImmediatePropagation() 을 이용하여 이후에 순차적으로 실행될 다른 이벤트 리스너들의 실행을 막고자 한 의도였지만 잘 안 됐다. 이유는 이벤트를 위임으로 지정한 것과 직접 지정한 것의 발동 순서 때문인 것으로 보인다.

무식한 방법

일단 뇌에 적은 부하를 가하여 구상 가능한 무식한 방법들로는 아래와 같은 것들이 있다.

  1. 일단 기존 채팅 스크립트의 로드를 어떻게든 막고, 기존 스크립트를 통채로 내 UserScript 에 복사한 후, 필요없는 부분만을 지워주는 방법을 생각해볼 수 있다. 이 방법은 어떤 곳에나 적용 가능한 방법이지만, 기존 코드가 수정될 때마다 매번 다시 작업을 해주어야 하므로 매우 귀찮은 작업이라 최후의 최후의 방법에 해당한다.
  2. 위 방법의 단점을 보완할 수 있는 방법으로, 동적으로 채팅 스크립트를 텍스트처럼 읽어서 구조를 분석하고, 필요없는 부분은 지우고 필요한 부분만 남긴뒤 js 형태로 동적 로드한다면, 기존 코드가 수정될 때마다 다시 작업을 해주는 수고를 덜 수 있을 것이다. 그런데 개발하는데 쓸데없이 수고가 많이 들 것이다.
  3. 우선 새로 올라오는 채팅에 a 태그가 감지되면 이것을 span 태그로 바꾸어서 a 태그에 걸린 이벤트가 발동되지 않도록 만들고, 이렇게만 하면 a 태그의 동작을 하지 않으니 span 태그에 mousedown 이벤트를 만들어서 href 에 해당하는 링크로 이동하도록 해주는 방법이 있다. css 에 cursor: pointer 와 적당한 hover 스타일을 주면 완전한 a 태그처럼 보이게 만들 수 있다. 이 방법이 그래도 가장 쉽게 적용할 수 있는 방법이지만 방법이 아름답지 않다.
그 외에 몇 가지 방법을 더 생각해볼 수는 있겠지만, 아래에서 설명할 "아름다운 방법"에 비하면 부족하다.

아름다운 방법

다음의 방법은 이벤트를 할당하는 함수를 덮어씌우고, 몇 번의 필터링을 통해 내가 지우거나 수정하길 원하는 이벤트인지를 확인한 후, 원하는 동작을 하기 위한 컨테이너로 감싼 함수를 리스너로 등록해주는 방법이다.

Element.prototype._addEventListener = Element.prototype.addEventListener;
Element.prototype.addEventListener = function(a,b,c){
    if(a === "click"){
        if(this.id === "msg_cont" && this.nodeName.toLowerCase() === "ul"){
            var _b = b;
            b = function(e){
                if($(e.target).parent("li.content").length !== 0){
                    return;
                }
                _b(e);
            };
        }
    }

    if(c==undefined)
        c=false;
    this._addEventListener(a,b,c);
};

다음의 순서로 원하는 이벤트를 찾는다.

  1. 클릭 이벤트인지 확인한다.
  2. 이 클릭 이벤트가 bind 된 요소가 ul#msg_cont 인 것을 대상으로 삼아 범위를 줄인다.
  3. 기존 이벤트 리스너에 해당하는 함수 b를 _b 에 복사한다.
  4. 새롭게 다시 만들 함수 b 에서는 클릭 이벤트의 target 의 부모가 li.content 인지 확인한다.
  5. 부모가 li.content 이 맞다면, 바로 리턴하여 원래 이벤트에 할당된 함수를 실행하지 않고 바로 중지한다.
    부모가 li.content 아니라면 정상적으로 실행해주어야 하는 이벤트이므로 원래 이벤트에 할당되어있던 함수 _b 를 실행한다.
  6. 함수 b를 리스너로 한 이벤트를 등록한다.

만약 this 로부터 내가 탈취하길 원하는 이벤트가 맞는지에 대한 모든 확인이 가능하다면 따로 기존 함수를 _b 에 복사하는 등의 작업을 할 필요는 없다. 본 글에 적은 예시만 보면 부모가 li.content 인지 확인할 필요가 있나 싶지만, 실제 코드는 더 다양한 요소가 있고 $("#msg_cont").on("click", "a", function(e)... 형태로 걸린 다른 많은 click 이벤트가 존재해서 위와 같이 필터링을 해준 것이다. 일단 전체적인 흐름은 위와 같으니 본인 상황에 맞게 하면 된다.

만약 이벤트를 등록하는 코드가 $(document).on("click", "body a", ... 처럼 이벤트 위임을 통한 방식을 취한다면 위의 코드에서 Element.prototype.addEventListener 대신 document.prototype.addEventListenerunsafeWindow.document.prototype.addEventListener 처럼 코드를 작성해야 할 수 있다.

이 방법의 장점은 기존 코드에 대한 많은 분석이 필요 없고, 기존 이벤트의 타입과 HTML 구조만 알면 상대적으로 쉽게 기존 이벤트를 수정하거나 무력화할 수 있다는 점이다. 복잡하게 소스코드 분석을 할 필요 없이 console 창에 $(e.target) 만 몇 번 찍어보면 된다. 또한 크롬에서 자바스크립트 디버거에 물리는 것도 위 방법을 사용하면 매우 편하다.

당연히 위의 코드는 이벤트를 할당하는 함수를 덮어씌우므로, 내가 없애길 원하는 이벤트가 bind 되기 전 위의 코드가 실행되어야 한다. 따라서 UserScript 의 경우 가능한한 빨리 Usersciprt 코드가 실행될 수 있도록 metadata block 에서 run_at 을 document-start 로 설정하고, UserScript 의 최상단에 위 코드를 작성해주어야 한다.