명령어 주입: git ls-remote

git ls-remote의 원격 인자를 통한 옵션 및 명령 주입

설명

사용자 입력을 git ls-remote의 원격 인자로 직접 전달하면 -로 시작하는 값이 옵션으로 해석될 수 있습니다. execFile이나 spawn으로 셸을 피하더라도 Git 자체의 옵션·전송 방식 처리는 남습니다. --upload-pack 같은 옵션은 실행할 프로그램에 영향을 줄 수 있으며, 실제 명령 실행 여부와 위치는 저장소, 전송 방식과 Git 구성에 따라 달라집니다.

잠재적 영향

  • 조작된 옵션이나 전송 방식으로 의도하지 않은 프로그램이 실행될 수 있습니다.
  • 프로세스 권한으로 접근 가능한 토큰, 소스 코드나 설정이 노출될 수 있습니다.
  • 승인하지 않은 호스트에 연결하거나 장시간 대기해 서비스 자원이 소모될 수 있습니다.

해결 방법

  • 클라이언트에서는 저장소 식별자만 받고, 서버의 신뢰하는 구성에서 승인된 원격 URL로 매핑하세요.
  • URL을 직접 받는 경우에도 승인된 목적지와 정확히 비교하세요. 프로토콜 접두사나 .git 확장자만으로 목적지를 신뢰하지 마세요.
  • ['ls-remote', '--', remote]처럼 옵션 끝을 명시하고 셸 없이 인자 배열을 전달하세요.
  • Git 실행 파일, 환경과 구성을 신뢰하는 값으로 관리하고, 네트워크 접근 범위와 실행 시간을 제한하세요. --는 목적지의 신뢰성이나 전송 방식까지 검증하지 않습니다.

예시

변경 전

javascript
const http = require("http");
const { execFile } = require("child_process");

const server = http.createServer((req, res) => {
  const url = new URL(req.url, "http://localhost");
  if (url.pathname === "/remote") {
    const r = url.searchParams.get("r") || "";
    // 취약: 사용자 입력을 그대로 원격 인자로 전달
    execFile("git", ["ls-remote", r], (err, stdout, stderr) => {
      res.statusCode = 200;
      res.end("done");
    });
    return;
  }
  res.end("ok");
});

server.listen(3000);

변경 후

javascript
const express = require("express");
const { execFile } = require("child_process");
const app = express();

// 사전 등록된 안전한 리모트만 허용
const REMOTE_MAP = Object.freeze({
  repoA: "https://github.com/example/repoA.git",
  repoB: "git@github.com:example/repoB.git",
});

// URL을 직접 받더라도 등록된 원격과 정확히 비교
function isSafeRemote(candidate) {
  if (typeof candidate !== "string") return false;
  if (/^-/u.test(candidate)) return false; // '-'로 시작 금지
  if (/\s/u.test(candidate)) return false; // 공백/개행 금지
  if (/(--upload-pack|^-u\b)/u.test(candidate)) return false; // 위험 옵션 차단
  return Object.values(REMOTE_MAP).includes(candidate);
}

app.get("/safe-ls-remote", (req, res) => {
  const key = String(req.query.key || "");
  const remote = Object.hasOwn(REMOTE_MAP, key) ? REMOTE_MAP[key] : undefined;
  if (!remote) {
    return res.status(400).send("invalid key");
  }

  // 옵션 경계 강제: '--' 뒤에 원격 인자를 둬서 옵션으로 해석되지 않도록 함
  execFile(
    "git",
    ["ls-remote", "--", remote],
    { timeout: 5000 },
    (err, stdout) => {
      if (err) return res.status(500).send("error");
      res.type("text/plain").send(stdout);
    }
  );
});

app.get("/validated-ls-remote", (req, res) => {
  const remote = String(req.query.remote || "");
  if (!isSafeRemote(remote)) return res.status(400).send("bad remote");
  execFile(
    "git",
    ["ls-remote", "--", remote],
    { timeout: 5000 },
    (err, stdout) => {
      if (err) return res.status(500).send("error");
      res.type("text/plain").send(stdout);
    }
  );
});

app.listen(3000);

변경 전에는 입력이 Git 옵션으로 해석될 수 있습니다. 변경 후의 두 경로는 등록된 원격만 선택하고 -- 뒤에 전달합니다. 객체의 상속 속성도 저장소 키로 허용하지 않습니다. 예시 URL과 실행 환경은 실제 승인된 값으로 구성하고, 저장소별 접근 권한을 별도로 확인하세요.

참조