
보안 회사 엠시소프트(Emsisoft)의 집계로 2,773개 조직이 MOVEit 사고의 피해를 입었습니다. 이 조직들을 연 것은 파일 전송 서버 MOVEit Transfer에 있던 SQL 인젝션 제로데이 하나(CVE-2023-34362)였습니다. 사고 경위는 해외편 MOVEit 2,773개 기관: 파일 전송 서버의 SQL 인젝션 하나에 있습니다. 이 글은 데이터로 들어온 입력이 쿼리나 화면, 파일 실행으로 바뀌는 네 자리와 각각을 막는 코드를 다룹니다.
이 시리즈의 공격을 실제 쇼핑몰 코드에서 직접 재연하고 막아 보는 인프런 강의가 열렸습니다. 실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩 · 이 글과 이어지는 수업: 3-1 ~ 3-6

| 위험 | 어디서 생기나 | 막는 법 한 줄 | OWASP |
|---|---|---|---|
| SQL 인젝션 | 입력값을 쿼리 문자열에 이어 붙이는 곳 | 값은 바인딩으로만 넘긴다 | A05 |
| 정렬·검색 조건 조작 | 칼럼 이름·정렬 방향을 요청값으로 끼우는 곳 | 이름표를 받아 허용 목록에서 칼럼 이름으로 바꾼다 | A05 |
| 저장형 XSS | 남이 쓴 글을 HTML로 그대로 출력하는 화면 | 출력할 때 인코딩하고, CSP로 허용한 스크립트만 실행한다 | A05·A02 |
| 파일 업로드 | 확장자와 형식 값만 보고 받는 업로드 | 파일 내용으로 형식을 확인하고, 비공개 저장소에 새 이름으로 둔다 | A05·A01 |
입력값을 쿼리 문자열에 이어 붙이면 그 값은 명령의 일부가 됩니다. 데이터로 들어온 글자가 따옴표나 키워드를 포함하면 쿼리 구조가 바뀝니다. MyBatis에서는 ${} 표기가 이 일을 합니다. ${}는 값을 쿼리 문자열에 그대로 끼우고, #{}는 자리표시(?)를 만든 뒤 값을 따로 넘깁니다.
특정 회사 코드가 아니라 개념을 보여 주는 예시입니다. 값은 가짜입니다.
// 위험한 모양: ${}는 입력값을 쿼리 문자열에 그대로 끼운다
@Select("SELECT id, username FROM ums_member WHERE username = '${username}'")
Member findByUsername(@Param("username") String username);
// 고친 모양: #{}는 자리표시를 만들고 값은 바인딩으로 넘긴다
@Select("SELECT id, username FROM ums_member WHERE username = #{username}")
Member findByUsername(@Param("username") String username);
// JdbcTemplate을 쓸 때도 같은 원리
Member m = jdbc.queryForObject(
"SELECT id, username FROM ums_member WHERE username = ?", memberRowMapper, "alice");
바인딩으로 넘긴 값은 데이터베이스가 데이터로만 다룹니다. 따옴표가 들어 있어도 쿼리 구조는 바뀌지 않습니다.
매퍼 XML과 @Select 문자열에서 ${를 소스 전체 검색으로 찾아 목록을 만듭니다. 남은 ${는 다음 절의 허용 목록 값만 받는지 하나씩 확인하고, 같은 검사를 정적 분석 규칙으로 CI에 넣습니다.
값을 모두 바인딩해도 정렬 조건은 빈틈으로 남기 쉽습니다. 칼럼 이름과 정렬 방향은 쿼리 구조의 일부라서 자리표시로 넘길 수 없습니다. 그래서 ORDER BY ${sort}처럼 문자열을 끼우는 표기를 쓰게 되고, 요청값이 그 자리에 그대로 들어가면 쿼리 모양이 바뀝니다.
// 고친 모양: 요청은 이름표만 받고, 서버가 실제 칼럼 이름으로 바꾼다
private static final Map<String, String> SORT_COLUMNS = Map.of(
"price", "price",
"best", "sale");
public List<Product> list(String sort, String dir) {
String column = SORT_COLUMNS.get(sort);
if (column == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
String direction = "asc".equalsIgnoreCase(dir) ? "ASC" : "DESC";
return productMapper.findAll(column, direction); // 매퍼의 ${column}에는 이 목록의 값만 들어간다
}
매퍼에는 여전히 ${column}이 남지만, 그 자리에 들어가는 값은 서버 코드에 적어 둔 상수뿐입니다. 정렬 방향도 ASC와 DESC 둘 중 하나만 나옵니다.
목록에 없는 정렬 값으로 요청하면 400이 나오는지 테스트합니다.
상품 후기는 저장될 때 그냥 글자입니다. 그런데 관리자 화면이 후기를 HTML로 그대로 출력하면, 후기 속 스크립트가 관리자의 브라우저에서 실행됩니다. 손님이 쓴 글을 관리자가 여는 구조라서, 그 스크립트는 관리자의 로그인 상태로 동작합니다. 이것을 저장형 XSS(Cross-Site Scripting, 저장된 글에 섞인 스크립트가 다른 사람의 브라우저에서 실행되는 문제)라고 부릅니다.
Thymeleaf에서 th:utext는 HTML을 그대로 출력하고, th:text는 꺾쇠 같은 기호를 글자로 바꿔(인코딩해) 출력합니다.
<td th:utext="${c.content}"></td>
<td th:text="${c.content}"></td>
한 겹 더 두는 것이 CSP(Content Security Policy, 브라우저가 실행해도 되는 스크립트의 출처를 서버가 정해 주는 응답 헤더)입니다. 세션 쿠키에는 스크립트가 읽지 못하게 하는 속성을 붙입니다.
@Bean
SecurityFilterChain web(HttpSecurity http) throws Exception {
http.headers(h -> h.contentSecurityPolicy(csp ->
csp.policyDirectives("script-src 'self'; object-src 'none'")));
return http.build();
}
# application.yml: 세션 쿠키 속성
server.servlet.session.cookie:
http-only: true # 스크립트에서 쿠키를 읽지 못한다
secure: true # HTTPS에서만 보낸다
same-site: lax # 다른 사이트에서 시작한 요청에는 붙지 않게 한다
꺾쇠가 들어간 후기를 저장하고 관리자 화면에서 글자로만 보이는지 확인합니다. 템플릿 전체에서 th:utext를 검색해 쓰는 곳마다 이유를 적어 둡니다. CSP 위반 보고를 모으면 놓친 출력 지점을 운영에서 찾을 수 있습니다.
확장자와 Content-Type 값은 보내는 쪽이 정합니다. 이름이 .png라도 내용은 이미지가 아닐 수 있습니다. 그 파일이 웹에서 바로 열리는 폴더에 원래 이름 그대로 놓이면, 서버나 다른 사용자의 브라우저에서 실행될 수 있습니다.
// 고친 모양: 크기 상한, 내용으로 형식 확인, 새 이름, 비공개 저장
private static final Set<String> ALLOWED = Set.of("image/png", "image/jpeg");
public String store(MultipartFile file) throws IOException {
if (file.getSize() > 5 * 1024 * 1024) throw badRequest(); // 크기 상한(예시 값)
String type = uploadValidator.sniff(file); // 파일 앞부분 바이트로 실제 형식 판별
if (!ALLOWED.contains(type)) throw badRequest();
String key = UUID.randomUUID().toString(); // 파일 이름은 서버가 새로 만든다
storage.putPrivate(key, type, file.getInputStream()); // 비공개 버킷에 저장
return key; // 내려 줄 때는 짧은 서명 URL로
}
uploadValidator는 강의에서 만드는 방어 부품(UploadValidator) 예시입니다. 이 부품은 파일 첫 몇 바이트(PNG, JPEG마다 정해진 시작 바이트)로 형식을 판별합니다. 저장소는 비공개로 두고, 내려 줄 때만 만료 시간이 짧은 서명 URL을 발급합니다.
이미지가 아닌 파일의 확장자만 .png로 바꿔 올리면 400이 나오는지 테스트합니다. 업로드 경로에서 거절이 갑자기 늘면 경보를 받도록 해 둡니다.
${를 모두 찾았고, 남은 곳은 서버 상수만 받는다th:text)을 쓴다네 가지 모두 데이터로 들어온 입력이 쿼리 구조, HTML, 실행 파일 같은 명령 자리로 넘어가서 생깁니다. 값은 바인딩으로 넘기고, 구조에 들어가는 칼럼 이름은 서버의 허용 목록에서 꺼냅니다. 화면에는 인코딩해서 출력하고 CSP로 한 겹 더 막습니다. 파일은 이름 대신 내용으로 형식을 확인합니다.
오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.
실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩
서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.
백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지
수치와 사실은 2026년 11월 2일 기준 공개 자료입니다. 이 글은 법률 자문이 아닙니다.
이전 글 남의 것이 보인다: 남의 번호 조회, 대량 할당, SSRF를 서버에서 막기
다음 글 비밀번호가 맞아도: 비밀번호 저장, 크리덴셜 스터핑, 토큰 수명
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록