운영체제 명령어 주입

운영체제 명령어 주입

설명

사용자 입력이 셸 명령문에 들어가면 ;, &&, | 같은 문자가 명령 문법으로 해석될 수 있습니다. Go의 os/exec는 기본적으로 셸을 실행하지 않지만, sh -c를 호출하거나 셸의 표준입력에 문자열을 쓰면 이 위험이 생깁니다.

잠재적 영향

  • 애플리케이션 프로세스 권한으로 의도하지 않은 명령이 실행될 수 있습니다.
  • 파일, 환경변수, 자격 증명이 읽히거나 데이터가 변경될 수 있습니다.
  • 고부하 명령으로 서비스가 중단될 수 있습니다. 관리자 권한 획득 여부는 기존 프로세스 권한이나 추가 취약점에 달려 있습니다.

해결 방법

  • 가능한 한 표준 라이브러리로 작업하고, 외부 프로그램이 필요하면 셸 없이 exec.Command에 실행 파일과 인자를 따로 전달하세요.
  • 실행 파일, 서브커맨드와 옵션을 코드에 고정하고 사용자 입력은 허용된 값으로 제한하세요. 인자를 분리해도 대상 프로그램의 위험한 옵션까지 막아 주지는 않습니다.
  • 셸을 반드시 사용해야 한다면 셸 종류와 삽입 위치에 맞게 각 값을 인용하세요. 이스케이프만으로 작업 권한이나 대상의 안전성이 검증되지는 않습니다.
  • 호출자의 권한을 확인하고 최소 프로세스 권한, 시간·자원 제한을 적용하세요.

예시

실행 파일의 절대 경로는 배포 환경에 맞게 확인해야 합니다. 예시는 인증·인가, TLS와 별도의 자원 격리를 생략합니다.

변경 전

go
package main

import (
    "io"
    "log"
    "net/http"
    "os/exec"
)

func unsafeHandler(w http.ResponseWriter, r *http.Request) {
    // 사용자가 전달한 명령 문자열 (예: name; rm -rf /)
    userCmd := r.URL.Query().Get("cmd")

    // BAD: 쉘을 띄우고 표준입력으로 사용자 입력을 그대로 전달
    // 사용자는 ;, &&, | 같은 메타문자를 섞어 추가 명령을 실행시킬 수 있음
    c := exec.Command("bash")
    stdin, err := c.StdinPipe()
    if err != nil {
        http.Error(w, "pipe error", http.StatusInternalServerError)
        return
    }
    if err := c.Start(); err != nil {
        http.Error(w, "start error", http.StatusInternalServerError)
        return
    }
    io.WriteString(stdin, userCmd+"\n")
    stdin.Close()
    _ = c.Wait()

    w.Write([]byte("done"))
}

func main() {
    http.HandleFunc("/run", unsafeHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

변경 후

go
package main

import (
    "log"
    "net/http"
    "os/exec"
    "regexp"
    "time"
    "context"
)

// 허용된 커맨드만 실행 (고정)
var allowed = map[string][]string{
    "whoami": {"/usr/bin/whoami"},
    "date":   {"/bin/date", "+%F %T"},
}

// 메시지 등 사용자 입력을 인자로 써야 할 때 안전 문자만 허용
var safeText = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,64}$`)

func safeHandler(w http.ResponseWriter, r *http.Request) {
    action := r.URL.Query().Get("action")

    // 1) 허용 목록으로 프로그램과 고정 인자 결정
    cmdDef, ok := allowed[action]
    if !ok {
        http.Error(w, "invalid action", http.StatusBadRequest)
        return
    }

    // 2) 선택적으로 사용자 입력을 인자로 추가해야 한다면 화이트리스트 검증
    msg := r.URL.Query().Get("msg")
    args := []string{}
    if msg != "" {
        if !safeText.MatchString(msg) {
            http.Error(w, "invalid msg", http.StatusBadRequest)
            return
        }
        // 예시: printf %s\n <msg> 형태로 안전하게 전달하려면 사전 정의할 것
        cmdDef = []string{"/usr/bin/printf", "%s\n", msg}
    }

    // 3) 쉘을 사용하지 않고 인자를 분리해 전달
    prog := cmdDef[0]
    if len(cmdDef) > 1 {
        args = cmdDef[1:]
    }

    // 4) 타임아웃으로 실행 제어
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    out, err := exec.CommandContext(ctx, prog, args...).CombinedOutput()
    if err != nil {
        http.Error(w, "exec error", http.StatusInternalServerError)
        return
    }

    w.Write(out)
}

func main() {
    http.HandleFunc("/run", safeHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

설명:

  • 변경 전: bash의 표준입력에 사용자 문자열을 전달하므로 셸 문법이 실행됩니다.
  • 변경 후: 고정된 실행 파일과 인자를 셸 없이 전달합니다. 유효한 action이 있고 msg도 있으면 고정 형식의 printf로 전환합니다. 문자 검증은 이 제한된 메시지 용도에 맞춘 것이며, 2초 타임아웃은 별도 메모리·출력·동시 실행 제한을 대신하지 않습니다.

참조