본문으로 바로가기

파일의 IT 블로그

  1. Home
  2. 웹/Chrome Extension
  3. [크롬 확장프로그램] 응답값 변조하는 방법 - XMLHttpRequest 속이기

[크롬 확장프로그램] 응답값 변조하는 방법 - XMLHttpRequest 속이기

· 댓글개 · KRFile

현재 크롬 확장프로그램을 개발중인데, 작업중인 사이트에서 XMLHttpRequest로 웹 요청을 보내고 있었고 이 응답값을 속여야만 제가 작성한 확장프로그램 자바스크립트가 정상작동하는 상황이였습니다.

 

사실 응답값을 속일수만 있으면 확장프로그램으로써 아주 많은 일들을 할 수 있습니다. 사실 처음에는 사이트에서 로딩하는 자바스크립트 자체를 변조하는 방법도 생각해봤는데, 생각보다 어렵더라구요.. 방법도 거의 없고 실제로 스크립트가 조금이라도 바뀌는 순간 기존 변조를 했던것도 깨져버리구요.

 

대신에 웹 요청은 바뀔일이 크게 없기 때문에 웹 응답값을 변조하기로 마음먹었습니다.

본 글에서는 웹 사이트에서 XMLHttpRequest 로 요청을 보낼때 들어오는 응답값을 속이는 방법을 소개합니다. 추가로 응답값 뿐만이 아니라 상태 코드(Status Code) 도 변조할 수 있습니다.

 

시작전에

해당 글에서 알려드리는 방법은 웹 사이트에서 요청을 XMLHttpRequest로 보내는 경우에만 해당됩니다. 만약에 fetch 같은 다른 API를 이용중이라면 이 방법으로는 웹 요청값을 변조할 수 없습니다. API에 맞게 각각 다른 방법을 알아보셔야 합니다.

 

예제로 알아보기

<!DOCTYPE html>
<html>
  <head>
    <title>XMLHttpRequest 예제</title>
  </head>
  <body>
    <button onclick="send_request()">요청 보내기</button>
    <p id="response"></p>

    <script>
      function send_request() {
        document.getElementById("response").innerText = "요청 전송중...";
        const xhr = new XMLHttpRequest();
        const url = "https://127.0.0.1/api";

        xhr.onreadystatechange = function () {
          // xhr.readyState 는 4가지 상태를 가지며, 4는 데이터를 전부 받은 상태를 의미한다.
          if (xhr.readyState === 4) {
            // 요청에 성공한 경우
            if (xhr.status === 200) {
              const response_text = xhr.responseText;
              if (response_text === "password-correct") {
                document.getElementById("response").innerText =
                  "암호 확인 성공";
              } else {
                document.getElementById(
                  "response"
                ).innerHTML = `암호 확인 실패<br>
                  응답값: ${response_text}`;
              }
            } else {
              document.getElementById("response").innerText = "서버 접속 실패";
            }
          }
        };

        xhr.open("GET", url, true);
        xhr.send();
      }
    </script>
  </body>
</html>

HTML, XMLHttpRequest 을 활용해 "https://127.0.0.1/api" 에 요청을 보내서 요청 성공시 응답 데이터가 password-correct 인 경우만 검사를 통과할 수 있는 예제입니다.

 

당연하지만 위 HTML 코드를 Live Server로 열어주고 요청 보내기를 누르면 서버 접속 실패가 뜹니다. "https://127.0.0.1/api" 란 주소를 가진 웹 사이트가 없기 때문입니다. 

이 요청에 대한 응답을 속여서 "암호 확인 성공" 이 뜨도록 변조해봅시다.

 

// 특정 url 주소와 status 코드에 대해서만 응답값을 변조하도록 설정
const filter = [];

// 필터링 조건이 맞는지 확인하는 함수
function apply_filter_test(url, xml_status, filter) {
  if (!(filter && filter.length > 0)) return false; // 필터가 없으면 변조 필터링이 적용되지 않아야 함
  for (const item of filter) {
    const { target, mode, status: filterStatus } = item;

    if (mode === "include" && url.includes(target)) {
      // include 모드인 경우 url이 target을 포함하는 경우에만 필터링합니다.
      if (filterStatus.includes(xml_status)) {
        return true; // 변조 필터링이 적용되어야 함
      }
    } else if (mode === "same" && url === target) {
      // same 모드인 경우 url이 target과 같은 경우에만 필터링합니다.
      if (filterStatus.includes(xml_status)) {
        return true; // 변조 필터링이 적용되어야 함
      }
    }
  }
  return false; // 변조 필터링이 적용되지 않아야 함
}

var _open = XMLHttpRequest.prototype.open;
window.XMLHttpRequest.prototype.open = function (method, url) {
  var _onreadystatechange = this.onreadystatechange,
    _this = this;

  _this.onreadystatechange = function () {
    try {
      console.log("Caught! :)", method, url /*, _this.responseText*/);
      console.log("ready_status", _this.readyState, "status", _this.status);
    } catch (e) {}

    // 요청이 완료된 경우에만 변조하도록 함 (readyState: 4)
    if (
      _this.readyState === 4 &&
      apply_filter_test(url, _this.status, filter)
    ) {
      try {
        //////////////////////////////////////
        // 이곳에 응답값 변조 로직을 작성합니다.
        //////////////////////////////////////
        console.log("응답값 변조 시작");
        console.log(_this);

        // 여기서 responseText (응답 데이터) 와 status (응답 코드) 를 변조합니다.
        Object.defineProperty(_this, "responseText", {
          value: "modified response text",
        });

        Object.defineProperty(_this, "status", {
          value: 200,
        });

        /////////////// 종료 //////////////////
      } catch (e) {}
    }
    // call original callback
    if (_onreadystatechange) _onreadystatechange.apply(this, arguments);
  };

  // detect any onreadystatechange changing
  Object.defineProperty(this, "onreadystatechange", {
    get: function () {
      return _onreadystatechange;
    },
    set: function (value) {
      _onreadystatechange = value;
    },
  });

  return _open.apply(_this, arguments);
};

오늘 삽입할 스크립트 입니다. 원리에 대해 간단히 작성해보자면, XMLHttpRequest이 객체라고 하더라도 어짜피 자바스크립트의 Class (=객체의 설계도) 는 뭔가 문법적으로 새로운 기능이 아니라 Prototype 이라고 하는 것의 Syntatic Sugar입니다. 그러므로 Java나 C# 같은 객체지향 언어처럼 캡슐화 등이 잘 지켜지지 않는것으로 알고 있습니다.

 

그러므로 XMLHttpRequest안에 들어 있는 웹 요청과 관련된 객체 안의 함수를 그냥 대입해서 재정의해주면 쉽게 웹 요청을 속일 수 있다는 겁니다. 

 

조금 더 쉽게 이해하자면 그냥 웹 요청을 할때 XMLHttpRequest 란 것을 쓰고 있는데, 여기서 웹 요청을 건다음 응답을 저장하고 다시 리턴하는 부분이 있을건데, 응답을 저장하고 다시 리턴하기 바로 전 저희가 제대로 된 응답이 저장된 변수를 덮어써 변조해버리면 응답 데이터를 속일 수 있다는 겁니다.

 

(주의 : 제가 자바스크립트를 많이 사용해보지 않아서 엄밀하지 않고 틀린 정보일수도 있습니다. 만약에 틀리면 님들 말이 다 맞음 ㅈㅅ 그냥 대략 제가 이해한 것을 작성해본 것이지만 그래도 거의 대부분 맞는 설명일겁니다.)

 

어.. 글로 쓰고보니깐 너무 이야기가 어렵게 된 느낌인데 그냥 웹 요청하는 코드 부분 덮어써서 응답 데이터만 속였다고 생각하면 될 거 같습니다. 

 

크롬 확장프로그램을 제작하기 전에 테스트를 먼저 해봐야 됩니다. 

응답을 변조할 사이트를 들어가서 크롬 확장자 도구를 열고 방금 위에서 본 코드를 그대로 콘솔에서 실행시켜 줍니다.

실행이 완료되었으면 요청 보내기를 한번 더 눌러봅니다.

 

요청 보내기를 누른 순간 저희가 onreadystatechange 함수를 재정의 했으므로 XMLHttpRequest의 readyState 값이 바뀜에 따라 이렇게 패킷이 잡히는 모습입니다.

 

https://developer-talk.tistory.com/843

 

[JavaScript]XMLHttpRequest의 readyState 이해하기

XMLHttpRequest의 readyState란? XMLHttpRequest의 readyState 프로퍼티는 XMLHttpRequest 객체의 상태를 보여줍니다. readyState 프로퍼티의 값이 변경될 때마다 readystatechange 이벤트가 실행되므로 해당 이벤트에서 rea

developer-talk.tistory.com

readyState 에 관해 궁금하신 분들은 여기 글을 보시면 되는데, readyState = 4 가 요청이 완료됐을때 입니다.

결론적으로 readyState 가 4일때 응답 데이터를 저희가 원하는 데이터로 써버리면 응답 값이 저희가 변조하고 싶은 데이터로 무조건 고정되는 겁니다.

 

현재 응답 데이터를 변조할 주소는 "https://127.0.0.1/api" 니깐 "api" 라는 문구가 포함되는 주소 중에서, status 코드가 0 인 경우만 응답 데이터를 변조하면 될 거 같습니다.

 

코드에서 바꿔야 할 부분은 2가지 입니다.

 

// 특정 url 주소와 status 코드에 대해서만 응답값을 변조하도록 설정
const filter = [
  {
    target: "api",
    mode: "include",
    status: [0],
  },
  // {
  //   target: "api2",
  //   mode: "same",
  //   status: [402, 204, 201],
  // },
];

상단 필터링 조건을 정의해줍니다. filter 부분에 다음과 같이 웹 요청 URL에 "api" 라는 단어가 포함 (include) 되고 status 코드가 0인 경우만 웹 응답을 변조하도록 합니다.

 

만약에 특정 주소에 대해 완전 일치로 필터링을 걸고 싶으시면 mode를 same으로 하시면 됩니다.  

  {
    target: "api2",
    mode: "same",
    status: [402, 204, 201],
   },

만약에 주석 처리된 이 조건도 추가하시면 주소가 api2 와 일치하고, status 코드는 402, 204 , 201 중 아무거나 일치하면 무조건 응답 데이터를 변조하도록 합니다. 필터링 예시로 보여드린 거고, 주석처리는 사실 필요한 분들만 푸시면 됩니다.

 

  try {
    //////////////////////////////////////
    // 이곳에 응답값 변조 로직을 작성합니다.
    //////////////////////////////////////
    console.log("응답값 변조 시작");
    console.log(_this);

    // 여기서 responseText (응답 데이터) 와 status (응답 코드) 를 변조합니다.
    Object.defineProperty(_this, "responseText", {
      value: "modified response text",
    });

    Object.defineProperty(_this, "status", {
      value: 200,
    });

    /////////////// 종료 //////////////////
  } catch (e) {}

이제 응답값을 변조할 차례입니다. 일단 status 코드는 200으로 변조해서, 없는 주소에 요청을 보내도 성공한 것처럼 속이고 응답 값은 modified response text 로 변조하도록 하겠습니다.

 

// 특정 url 주소와 status 코드에 대해서만 응답값을 변조하도록 설정
const filter = [
  {
    target: "api",
    mode: "include",
    status: [0],
  },
  // {
  //   target: "api2",
  //   mode: "same",
  //   status: [402, 204, 201],
  // },
];

// 필터링 조건이 맞는지 확인하는 함수
function apply_filter_test(url, xml_status, filter) {
  if (!(filter && filter.length > 0)) return false; // 필터가 없으면 변조 필터링이 적용되지 않아야 함
  for (const item of filter) {
    const { target, mode, status: filterStatus } = item;

    if (mode === "include" && url.includes(target)) {
      // include 모드인 경우 url이 target을 포함하는 경우에만 필터링합니다.
      if (filterStatus.includes(xml_status)) {
        return true; // 변조 필터링이 적용되어야 함
      }
    } else if (mode === "same" && url === target) {
      // same 모드인 경우 url이 target과 같은 경우에만 필터링합니다.
      if (filterStatus.includes(xml_status)) {
        return true; // 변조 필터링이 적용되어야 함
      }
    }
  }
  return false; // 변조 필터링이 적용되지 않아야 함
}

var _open = XMLHttpRequest.prototype.open;
window.XMLHttpRequest.prototype.open = function (method, url) {
  var _onreadystatechange = this.onreadystatechange,
    _this = this;

  _this.onreadystatechange = function () {
    try {
      console.log("Caught! :)", method, url /*, _this.responseText*/);
      console.log("ready_status", _this.readyState, "status", _this.status);
    } catch (e) {}

    // 요청이 완료된 경우에만 변조하도록 함 (readyState: 4)
    if (
      _this.readyState === 4 &&
      apply_filter_test(url, _this.status, filter)
    ) {
      try {
        //////////////////////////////////////
        // 이곳에 응답값 변조 로직을 작성합니다.
        //////////////////////////////////////
        console.log("응답값 변조 시작");
        console.log(_this);

        // 여기서 responseText (응답 데이터) 와 status (응답 코드) 를 변조합니다.
        Object.defineProperty(_this, "responseText", {
          value: "modified response text",
        });

        Object.defineProperty(_this, "status", {
          value: 200,
        });

        /////////////// 종료 //////////////////
      } catch (e) {}
    }
    // call original callback
    if (_onreadystatechange) _onreadystatechange.apply(this, arguments);
  };

  // detect any onreadystatechange changing
  Object.defineProperty(this, "onreadystatechange", {
    get: function () {
      return _onreadystatechange;
    },
    set: function (value) {
      _onreadystatechange = value;
    },
  });

  return _open.apply(_this, arguments);
};

웹 사이트에 삽입시킬 전체 코드는 다음과 같습니다.

다시 웹 사이트에 가서 해당 코드를 사이트에 실행시킵니다.

 

이제 사이트에 가서 요청을 보내본 결과 네트워크 에러가 뜨지 않고 응답값이 "modified response text" 라고 속은 모습입니다. WOW

 

 

참고로 주의할 점은 Status Code와 응답 데이터를 속이는 것은 크롬 개발자 도구에는 반영되지 않는 다는 점입니다. 크롬 개발자 도구는 오가는 패킷 자체를 캡쳐하기 때문에 그런 거 같습니다. 개발자 도구에만 확인이 안될 뿐이지 웹 사이트 자체는 요청값과 Status Code 를 속인걸 모르고 그대로 인식합니다.

 

어짜피 웹 사이트 자체에서는 XMLHttpRequest 객체의 함수, 변수들이 재정의 된 걸 모르기 때문이죠. 

 

 Object.defineProperty(_this, "responseText", {
          value: "password-correct",
        });

사이트에서 제대로 인식할 수 있도록 응답 데이터를 password-correct 로 인식시키도록 변경합니다.

그리고 다시 사이트에서 응답값을 속이는 스크립트를 실행합니다.

 

+ 추가로 잡설이지만 responseText 는 무조건 문자열이라서 object 타입(json)을 value에 적고 싶다면  JSON.stringify({}) 를 활용하여 object 를 string 으로 바꿔주도록 합시다.

 

 

암호 확인 성공이 된 모습입니다! 하하

이제 작성이 완료된 스크립트를 크롬 확장 프로그램으로 포장해서 사이트에 들어가면 자동 실행되도록 한다면 이제 저 버튼은 무조건 암호 확인 성공 버튼이 되는 것이죠. 참 쉽죠?

 

크롬 확장 프로그램 제작

{
  "name": "Response Modify Script",
  "version": "1.0",
  "description": "This extension will run specific script after the site has loaded.",
  "manifest_version": 3,
  "content_scripts": [
    {
      "matches": ["http://127.0.0.1:5500/*"],
      "js": ["inject.js"]
    }
  ],
  // 여기에 proxy.js를 리소스로 등록해야 inject.js에서 인젝션 할 수 있음.
  "web_accessible_resources": [
    {
      "resources": ["proxy.js"],
      "matches": ["<all_urls>"]
    }
  ]
}

폴더를 하나 만든 다음 manifest.json 를 만들고 이런 내용을 적어줍니다. 

주소는 VSCode LiveServer로 돌리고 있어서 localhost에 5500번이 매칭되도록 했습니다.

 

// 크롬에서 content script 들은 DOM 과는 동기화 되어 있어서 DOM에는 바로 접근이 가능하지만.
// 변수는 동기화 되어 있지 않기 때문에 페이지의 window 변수 같은것에 접근할 수 없음.
// 접근을 위해 일종의 꼼수 사용. 페이지에 script 태그로써 삽입하여 window 변수나 페이지 내의 변수를 접근할 수 있게 한다.

function inject_script(file, node) {
  var th = document.getElementsByTagName(node)[0];
  var s = document.createElement("script");
  s.setAttribute("type", "text/javascript");
  s.setAttribute("src", file);
  th.appendChild(s);
}

//body 태그의 자식으로써 js를 삽입한다.
inject_script(chrome.runtime.getURL("proxy.js"), "body");

그리고 inject.js 에 다음 코드를 작성합니다. 그냥 쌩 컨텐츠 스크립트로 방금 작성한 XMLHttpRequest 변조 스크립트를 실행시키면 작동하지 않습니다. 크롬 확장 프로그램은 DOM은 작업하려는 웹 사이트와는 공유하지만 변수들은 공유되지 않습니다. 그래서 크롬 확장 프로그램으로 웹 사이트의 window 변수나 여러 변수들, 함수들에 접근할 수 없습니다.

 

조금 꼼수를 써서 컨텐츠 스크립트에서 동적으로 script 코드를 사이트에 박아서 실행시키도록 합니다.

이렇게 하면 웹 사이트에서 script 코드로써 저희가 작성한 코드가 돌아가서 문제 없이 웹 사이트와 저희가 작성한 코드가 변수, 함수들을 공유하게 됩니다.

 

단 주의할 점은 여러개를 이런식으로 사이트에 script 태그로 그냥 박으면 변수명, 함수명이 겹칠 수 있는 문제가 있으므로 너무 여러개의 스크립트를 사이트에 인젝션(삽입) 하지마세요.

 

저는 일단 proxy.js 를 웹 사이트에 그대로 삽입시킬겁니다.

 

console.log("script run start");

// 특정 url 주소와 status 코드에 대해서만 응답값을 변조하도록 설정
const filter = [
  {
    target: "api",
    mode: "include",
    status: [0],
  },
  // {
  //   target: "api2",
  //   mode: "same",
  //   status: [402, 204, 201],
  // },
];

// 필터링 조건이 맞는지 확인하는 함수
function apply_filter_test(url, xml_status, filter) {
  if (!(filter && filter.length > 0)) return false; // 필터가 없으면 변조 필터링이 적용되지 않아야 함
  for (const item of filter) {
    const { target, mode, status: filterStatus } = item;

    if (mode === "include" && url.includes(target)) {
      // include 모드인 경우 url이 target을 포함하는 경우에만 필터링합니다.
      if (filterStatus.includes(xml_status)) {
        return true; // 변조 필터링이 적용되어야 함
      }
    } else if (mode === "same" && url === target) {
      // same 모드인 경우 url이 target과 같은 경우에만 필터링합니다.
      if (filterStatus.includes(xml_status)) {
        return true; // 변조 필터링이 적용되어야 함
      }
    }
  }
  return false; // 변조 필터링이 적용되지 않아야 함
}

var _open = XMLHttpRequest.prototype.open;
window.XMLHttpRequest.prototype.open = function (method, url) {
  var _onreadystatechange = this.onreadystatechange,
    _this = this;

  _this.onreadystatechange = function () {
    try {
      console.log("Caught! :)", method, url /*, _this.responseText*/);
      console.log("ready_status", _this.readyState, "status", _this.status);
    } catch (e) {}

    // 요청이 완료된 경우에만 변조하도록 함 (readyState: 4)
    if (
      _this.readyState === 4 &&
      apply_filter_test(url, _this.status, filter)
    ) {
      try {
        //////////////////////////////////////
        // 이곳에 응답값 변조 로직을 작성합니다.
        //////////////////////////////////////
        console.log("응답값 변조 시작");
        console.log(_this);

        // 여기서 responseText (응답 데이터) 와 status (응답 코드) 를 변조합니다.
        Object.defineProperty(_this, "responseText", {
          value: "password-correct",
        });

        Object.defineProperty(_this, "status", {
          value: 200,
        });

        /////////////// 종료 //////////////////
      } catch (e) {}
    }
    // call original callback
    if (_onreadystatechange) _onreadystatechange.apply(this, arguments);
  };

  // detect any onreadystatechange changing
  Object.defineProperty(this, "onreadystatechange", {
    get: function () {
      return _onreadystatechange;
    },
    set: function (value) {
      _onreadystatechange = value;
    },
  });

  return _open.apply(_this, arguments);
};

proxy.js 폴더에는 저희가 방금 만든 코드를 그대로 써줍니다.

 

다 작성하면 구조는 이렇게 됩니다. XMLHttpRequest-test.html 파일은 아까 LiveServer로 돌리면서 보여드린 HTML 예제 파일입니다.

 

작성이 끝났으면 크롬 브라우저에서 작성한 확장 프로그램을 설치해줍니다. 활성화도 체크!

 

참고로 확장 프로그램의 경우 확장 프로그램 탭으로 이동해서 압축해제된 확장 프로그램을 로드합니다. 를 누르면 됩니다. 압축할 필요 없고 저희가 작성한 manifest.json 이 있는 곳의 폴더를 열어주면 자동으로 확장 프로그램 설치가 끝납니다.

 

이러한 설치법의 경우 오른쪽 위의 개발자 모드를 반드시 켜주셔야 합니다.

 

 

이제 사이트에 들어가면 자동으로 저희가 작성한 스크립트가 실행되며, 응답값이 문제 없이 변조되는 모습을 볼 수 있습니다.

 

해결 완료! GOOD~

 

코드 다운로드

https://github.com/pgh268400/XMLHttpRequest_Modification_Extension

 

GitHub - pgh268400/XMLHttpRequest_Modification_Extension: XMLHttpRequest 응답 데이터를 속이는 크롬 확장프로그램

XMLHttpRequest 응답 데이터를 속이는 크롬 확장프로그램 예제. Contribute to pgh268400/XMLHttpRequest_Modification_Extension development by creating an account on GitHub.

github.com

Proxy.zip
0.00MB

 

본 글에서 사용한 전체 예제 및 코드는 여기서 받아가시면 됩니다.

 

 

 

참고

https://stackoverflow.com/questions/72987662/override-http-responses-from-a-chrome-extension

 

Override HTTP responses from a Chrome extension

I'm making an extension that will be able to modify request responses. I did some research, found this answer https://stackoverflow.com/a/51594799/16661157 and implemented it into my extension. When

stackoverflow.com

https://stackoverflow.com/questions/18310484/modify-http-responses-from-a-chrome-extension/51594799#51594799

 

Modify HTTP responses from a Chrome extension

Is it possible to create a Chrome extension that modifies HTTP response bodies? I have looked in the Chrome Extension APIs, but I haven't found anything to do this.

stackoverflow.com

https://stackoverflow.com/questions/20499994/access-window-variable-from-content-script

 

Access window variable from Content Script

I have a Chrome Extension that is trying to find on every browsed URL (and every iframe of every browser URL) if a variable window.my_variable_name exists. So I wrote this little piece of content ...

stackoverflow.com

 

SNS 공유하기
💬 댓글 개
이모티콘창 닫기
울음
안녕
감사해요
당황
피폐

이모티콘을 클릭하면 댓글창에 입력됩니다.