Technote

보안 실무

$wpdb 직접 쿼리 — prepare 를 빠뜨리는 네 자리

규칙 자체는 짧습니다. 문제는 자리표시자가 통하지 않는 자리가 정해져 있다는 점이고, 리뷰에서 놓치는 것도 늘 그 네 곳입니다.

워드프레스는 대부분의 조회를 WP_Query 와 get_posts() 로 처리할 수 있게 만들어 두었고, 그 경로에는 이스케이프가 이미 들어 있습니다. 그래도 집계나 조인이 필요한 순간에는 $wpdb 를 직접 쓰게 되고, 그 순간부터 이스케이프의 책임이 코어에서 우리에게로 넘어옵니다.

규칙 자체는 한 줄입니다 — 값은 자리표시자로 넘긴다. 어려운 것은 규칙이 아니라 자리표시자가 통하지 않는 자리이고, 코드 리뷰에서 놓치는 것도 늘 그 자리들입니다.

값은 전부 자리표시자로 넘깁니다

$wpdb->prepare() 는 SQL 문자열과 값을 따로 받습니다. 값이 들어갈 자리에는 %s(문자열) · %d(정수) · %f(실수) 만 적고, 실제 값은 인자로 전달합니다. 문자열을 이어 붙여 만든 SQL 은 값과 구문의 경계가 사라진 상태이고, 그 경계가 사라진 것이 SQL 인젝션의 정의입니다.

// ✗ 요청 값을 SQL 문자열에 이어 붙인 형태. 리뷰에서 가장 먼저 잡아야 하는 모양이다.
$rows = $wpdb->get_results( "SELECT ID FROM {$wpdb->posts} WHERE post_author = " . $_GET['author'] );

// ✓ 값 자리에는 자리표시자만, 실제 값은 인자로
$author_id = absint( $_GET['author'] ?? 0 );

$rows = $wpdb->get_results(
	$wpdb->prepare(
		"SELECT ID FROM {$wpdb->posts} WHERE post_author = %d AND post_status = %s",
		$author_id,
		'publish'
	)
);

여기서 첫 번째 함정이 나옵니다. 자리표시자에 따옴표를 두르지 않습니다. prepare() 가 값을 인용부호로 감싸 주므로 우리가 '%s' 라고 적으면 인용이 겹칩니다. 최근 코어는 이 경우 따옴표를 걷어내고 _doing_it_wrong() 알림을 남깁니다 — 화면은 멀쩡히 도는데 로그만 계속 “이 코드는 틀렸다” 고 말하는 상태가 됩니다.

// ✗ 인용이 겹친다
$wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_name = '%s'", $slug );

// ✓ 자리표시자만 둔다
$wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s", $slug );

테이블 이름은 값이 아닙니다

두 번째 자리입니다. %s 는 받은 값을 인용부호로 감싸므로 테이블 이름 자리에 쓰면 FROM 'wp_postmeta' 가 되어 문법이 깨집니다. 식별자(테이블 · 컬럼 이름)는 값이 아니라서 값 자리표시자에 들어가지 않습니다.

워드프레스 6.2 부터 식별자 전용 자리표시자 %i 가 있어 백틱으로 감싸 줍니다. 그래도 허용 목록을 함께 둡니다 — 이름이 안전하게 인용되는 것과, 우리가 노출할 의도가 없던 테이블을 조회하지 않는 것은 다른 문제입니다. 허용 목록은 이스케이프가 아니라 의도를 코드에 적는 일입니다.

// ✗ 값 자리표시자에 테이블 이름을 넣을 수 없다
$wpdb->prepare( "SELECT * FROM %s WHERE post_id = %d", $table, $id );

// ✓ 허용 목록에서 고르고, 목록에 없으면 질의 자체를 하지 않는다
$allowed = array(
	'meta'  => $wpdb->postmeta,
	'terms' => $wpdb->term_relationships,
);

if ( ! isset( $allowed[ $which ] ) ) {
	return array();
}

$table = $allowed[ $which ];

$rows = $wpdb->get_results(
	$wpdb->prepare( "SELECT * FROM {$table} WHERE post_id = %d", $id )
);

IN 절과 LIKE — 자리표시자를 만들어야 하는 두 곳

세 번째는 IN 입니다. IN (%s) 에 배열을 넘기면 배열 하나가 문자열 하나로 인용되어 조건이 통째로 어긋납니다. 항목 수만큼 자리표시자를 생성해야 합니다. 그리고 빈 배열은 IN () 이 되어 문법 오류를 내므로 질의 전에 끊습니다.

$ids = array_filter( array_map( 'absint', (array) $ids ) );

if ( ! $ids ) {
	return array();   // IN () 을 만들지 않는다
}

$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );

$found = $wpdb->get_col(
	$wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID IN ({$placeholders})", $ids )
);

네 번째는 LIKE 입니다. LIKE 는 % 와 _ 를 와일드카드로 해석하므로, 사용자가 입력한 문자열에 그 글자가 들어 있으면 검색 범위가 의도와 달라집니다. $wpdb->esc_like() 로 먼저 무력화한 뒤 우리가 붙일 와일드카드를 감싸고, 그 결과 전체를 %s 로 넘깁니다. 순서가 규칙입니다 — prepare() 뒤에 esc_like() 를 부르면 아무 의미가 없습니다.

// 순서: esc_like() → 와일드카드 부착 → prepare()
$like = '%' . $wpdb->esc_like( $keyword ) . '%';

$rows = $wpdb->get_results(
	$wpdb->prepare( "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_title LIKE %s", $like )
);

리뷰에서 알아보는 신호

이 네 자리는 전부 읽으면 보이는 모양을 갖고 있습니다. 그래서 코드 리뷰의 체크 항목으로 만들 수 있고, 만들어 두면 사람이 바뀌어도 같은 기준이 유지됩니다.

직접 쿼리 리뷰 항목 — 위 다섯 줄은 발견 즉시 반려한다

마지막으로 한 가지 덧붙이면, $wpdb->insert() · update() · delete() 는 형식 배열을 받아 이스케이프를 스스로 처리합니다. 쓰기 작업에서 SQL 문자열을 손으로 조립할 이유는 거의 없습니다. 조회도 가능하면 WP_Query 로 되돌아가는 편이 낫습니다 — 직접 쿼리로 내려가는 순간 오브젝트 캐시 · 훅 · 언어 필터를 전부 우리가 다시 챙겨야 하기 때문입니다.

입력 처리와 권한 확인을 함께 다루는 글은 보안 아카이브에 모여 있고, 남이 만든 코드 위에서 이런 패턴을 찾아 고치는 실제 절차는 작업 과정에 공개돼 있습니다. 운영 중인 사이트의 취약점 진단과 정비를 한 번에 맡기고 싶다면 최적화 지원 사업을 참고하세요.

이 주제의 다른 글

노하우 목록으로

보안 입문

업데이트 순서 — 코어 · 플러그인 · 테마

한 번에 전부 올리면 빠르지만, 깨졌을 때 무엇 때문인지 알 수 없습니다. 순서를 정하고 사이사이에 확인하는 것은 느린 방법이 아니라 원인을 특정할 수 있게 하는 방법입니다.

운영 · 관리자 3분 읽기

₩270,000 · 신청하기