템플릿 인젝션(Template Injection)

신뢰할 수 없는 입력을 서버 측 템플릿 소스로 컴파일하거나 평가하는 취약점

설명

서버 사이드 템플릿 인젝션(SSTI)은 신뢰할 수 없는 입력이 일반 데이터가 아니라 실행 가능한 템플릿 소스로 컴파일되거나 평가될 때 발생합니다. 공격자는 템플릿 지시문과 식을 제공하여 템플릿에 노출된 객체, 메서드, 헬퍼 또는 애플리케이션 데이터를 사용할 수 있습니다.

다음 세 값은 구분해야 합니다.

  • 템플릿 소스: 엔진이 문법으로 해석하는 본문입니다. 요청 데이터로 만들면 SSTI가 발생할 수 있습니다.
  • 템플릿 식별자: 서버가 관리하는 템플릿을 찾기 위한 이름이나 경로입니다. 신뢰할 수 없는 식별자는 경로 탐색이나 허가되지 않은 템플릿 선택 문제가 될 수 있지만, 그 값 자체가 곧 템플릿 소스인 것은 아닙니다.
  • 모델 또는 컨텍스트 값: 고정된 템플릿에 바인딩되는 데이터입니다. 사용자 입력은 이 경로로 전달해야 합니다.

예를 들어 일반 파일 로더를 사용하는 Configuration.getTemplate(userInput)은 템플릿 이름을 선택하지만, new Template("dynamic", userInput, configuration)은 입력을 FreeMarker 소스로 파싱합니다. 로더가 문자열을 소스로 해석하도록 구성된 경우에도 그 문자열을 신뢰할 수 있어야 합니다.

잠재적 영향

  • 템플릿에 노출된 데이터 또는 애플리케이션 객체 조회
  • 등록된 헬퍼나 접근 가능한 메서드를 통한 애플리케이션 기능 호출
  • 반복, 재귀 또는 큰 결과 생성을 이용한 CPU·메모리 고갈
  • 엔진 기능, 컨텍스트 객체 및 프로세스 권한이 허용하는 경우 서버 측 코드 또는 OS 명령 실행
  • 렌더링 결과가 HTML에 삽입되고 별도의 출력 인코딩이 없다면 XSS

모든 엔진과 설정이 곧바로 원격 코드 실행을 허용하는 것은 아닙니다. 실제 영향은 템플릿 언어의 기능, 등록된 헬퍼, 객체 접근 정책, 데이터 모델, 파일·네트워크 접근 권한에 따라 달라집니다.

해결 방법

  1. 템플릿 소스는 버전 관리되거나 서버가 관리하는 고정 리소스와 상수 문자열에서만 가져옵니다.
  2. 요청 값은 템플릿 문자열에 이어 붙이지 말고 모델 또는 컨텍스트 데이터로 전달합니다.
  3. HTML, JavaScript, URL 등 최종 출력 컨텍스트에 맞는 인코딩은 별도로 적용합니다. 출력 인코딩은 XSS를 줄이는 제어이며, 신뢰할 수 없는 템플릿 소스를 안전하게 만드는 새니타이저가 아닙니다.
  4. 사용자가 템플릿을 작성해야 하는 기능은 예외적인 고위험 기능으로 취급합니다. 노출 객체와 헬퍼를 최소화하고 엔진별 제한을 적용하며, 낮은 권한과 CPU·메모리·시간 제한을 둔 격리 프로세스에서 렌더링합니다.

엔진의 보안 옵션을 일반적인 “샌드박스”로 간주해서는 안 됩니다.

  • Velocity의 SecureUberspector는 신뢰할 수 없는 템플릿 작성자를 위한 출발점이지만, Velocity 공식 안내도 애플리케이션이 컨텍스트에 넣는 모든 위험 객체를 엔진이 미리 알 수 없다고 설명합니다.
  • FreeMarker에서 신뢰할 수 없는 템플릿이 불가피하다면 공식 FAQ에 따라 TemplateClassResolver.ALLOWS_NOTHING_RESOLVER 또는 더 엄격한 허용 정책을 검토해야 합니다. SAFER_RESOLVER만으로는 충분하지 않으며, FreeMarker 자체는 CPU와 메모리 한도를 강제할 수 없습니다.
  • Groovy 템플릿 엔진은 템플릿을 Groovy 코드로 컴파일하므로, 공격자가 제어하는 템플릿 본문을 같은 애플리케이션 프로세스에서 실행하지 않는 것이 기본 원칙입니다.

예시

Apache Velocity

변경 전

요청 값을 VTL 소스로 평가합니다.

java
import java.io.StringWriter;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.Velocity;

class InsecureVelocityTemplate {
    void render(HttpServletRequest request) {
        String userTemplate = request.getParameter("template");
        Velocity.evaluate(
            new VelocityContext(),
            new StringWriter(),
            "request-template",
            userTemplate
        );
    }
}

변경 후

템플릿 소스를 고정하고 요청 값을 컨텍스트 데이터로 전달합니다.

java
import java.io.StringWriter;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.Velocity;

class SecureVelocityTemplate {
    void render(HttpServletRequest request) {
        VelocityContext context = new VelocityContext();
        context.put("name", request.getParameter("name"));

        Velocity.evaluate(
            context,
            new StringWriter(),
            "welcome-template",
            "Hello $name"
        );
    }
}

위 안전 예시는 SSTI 경계만 보여 줍니다. 결과를 HTML로 보낼 때는 name에 대한 HTML 컨텍스트 인코딩도 별도로 적용해야 합니다.

FreeMarker

변경 전

요청 값을 템플릿 본문으로 파싱합니다.

java
import java.io.StringWriter;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
import freemarker.template.Configuration;
import freemarker.template.Template;

class InsecureFreeMarkerTemplate {
    void render(HttpServletRequest request, Configuration configuration) throws Exception {
        String userTemplate = request.getParameter("template");
        Template template = new Template("dynamic", userTemplate, configuration);
        template.process(Map.of(), new StringWriter());
    }
}

변경 후

서버가 관리하는 템플릿을 고정된 이름으로 로드하고 요청 값을 데이터 모델에만 넣습니다.

java
import java.io.StringWriter;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
import freemarker.template.Configuration;
import freemarker.template.Template;

class SecureFreeMarkerTemplate {
    void render(HttpServletRequest request, Configuration configuration) throws Exception {
        Template template = configuration.getTemplate("welcome.ftl");
        Map<String, Object> model = Map.of("name", request.getParameter("name"));
        template.process(model, new StringWriter());
    }
}

참조