Resumen
Secure Custom Fields (SCF) es un plugin de campos personalizados para WordPress. Sus campos "de referencia" (Relationship, Post Object y Page Link) guardan IDs de entradas y, al mostrarlos, los convierten en objetos WP_Post con la función auxiliar acf_get_posts().
El problema está en esa función (includes/api/api-helpers.php:1168):
- fija
post_status => 'any'(línea 1179), que en WordPress 7.1.3 incluyedraft,pendingyprivate; - llama a
get_posts()(línea 1211) sin comprobarcurrent_user_can( 'read_post' ), niis_post_publicly_viewable(), nipost_password_required(); - usa
posts_per_page => -1(línea 1177), es decir, sin límite de resultados.
Resultado: si un campo de referencia apunta a una entrada privada, un borrador o un post con contraseña, cualquier visitante sin sesión recibe el objeto completo: cuerpo, extracto, autor, fechas, enlace permanente y el post_password en texto plano. Es justo el contenido que el núcleo de WordPress le niega a ese mismo visitante: la web responde 404, la API REST devuelve 401 rest_forbidden y el post protegido muestra el formulario de contraseña en lugar del texto.
El investigador explica que esto es un parche incompleto. Versiones anteriores de SCF ya habían cerrado otras vías:
| Versión | Qué se protegió | |---|---| | 6.9.3 | Parámetro $enforce_read_permissions en acf_get_grouped_posts(), conectado solo a las tres llamadas AJAX (commit b520d2c) | | 6.9.6 | Comprobación en la API REST, incluido post_password_required() (includes/rest-api/acf-rest-api-functions.php:501-525) | | 6.9.7 | expose_reference_target() y strip_post_passwords() para los block bindings (includes/Blocks/Bindings.php:145,176) |
Pero seguían llamando a la función sin protección el renderizado del campo Relationship (class-acf-field-relationship.php:532), los format_value() de Relationship, Post Object y Page Link (:751, class-acf-field-post_object.php:564, class-acf-field-page_link.php:349,563) y el generador de JSON-LD de Page Link (page_link.php:720,726).
El fallo está en la lectura, no en cómo llega la referencia. En el escenario demostrado, el atacante la mete con un formulario acf_form() público, pero el investigador comprobó que la fuga es idéntica si la referencia la escribe un editor o WP-CLI directamente en los metadatos: la página anónima sigue mostrando Private: <título> y get_field() devuelve el cuerpo privado. La firma del formulario (includes/forms/form-front.php:868-890) autoriza qué campos se pueden guardar, pero no dice nada sobre si quien envía puede leer las entradas a las que apunta.
Pasos de reproducción
Entorno del investigador: WordPress 7.1.3 con SCF 6.9.7.
- Crear una entrada privada (ID 809), un borrador y una entrada protegida con contraseña (ID 811, contraseña de prueba
hunter2). Crear una página pública que muestreacf_form()con un campo Relationship (field_poc_def_rel,post_type: post,page) asociado a otra página pública de destino. - Como visitante sin sesión (usuario 0) y sin cookies, comprobar la línea base: la entrada privada da
404, la API REST401 rest_forbidden, el post protegido muestra el formulario de contraseña ycurrent_user_can( 'read_post', 809 )esfalse. - Pedir la página del formulario y quedarse con los campos ocultos que la autorizan:
GET /?page_id=<página del formulario>
_acf_nonce, _acf_form, _acf_form_meta[], _acf_render_id,
_acf_post_id, _acf_screen, _acf_validation
- Enviar el formulario con el ID de la entrada privada:
POST /?page_id=<página del formulario>
acf[field_poc_def_rel][]=809
+ los campos ocultos del paso anterior
La respuesta es un 302 a la URL de retorno y la página de destino guarda ahora poc_def_rel = ["809"].
- Volver a pedir la página. El campo muestra el título de la entrada privada, mientras su enlace permanente sigue dando
404al mismo visitante:
<span …>Private: POC-DEF PRIVATE TITLE (private)</span>
- Como usuario 0,
get_field( 'poc_def_rel', <página de destino> )devuelve unWP_Postconpost_status = "private", elpost_contentcompleto,post_excerpt,post_authory las fechas. - Repetir enviando
811: la misma llamada devuelvepost_password = "hunter2"y el cuerpo protegido. Para confirmar que la contraseña filtrada sirve:
POST /wp-login.php?action=postpass
post_password=hunter2&redirect_to=/?p=811
Con la cookie devuelta, GET /?p=811 responde 200 con el contenido protegido (una contraseña errónea no muestra nada).
- Para medir el alcance, enviar 50 IDs privados en una sola petición (
acf[field_poc_def_rel][]=…repetido 50 veces). Al recargar aparecen los 50 títulos: no hay tope por elposts_per_page => -1.
Impacto
El investigador lo valoró como severidad alta (CVSS 7.5, confidencialidad alta). Un atacante sin cuenta podía:
- Leer el contenido completo de entradas privadas, borradores y pendientes de revisión, con autor, fechas, slug y enlace permanente, saltándose los controles que el núcleo de WordPress aplica en la web y en la API REST.
- Obtener en texto plano la contraseña de los posts protegidos y usarla para abrirlos por la vía normal.
- Volcar contenido no publicado de forma masiva, decenas de IDs en una sola petición, sin paginación que lo frene.
El reporte figura como resuelto y con recompensa, de importe no público.
Qué aprender de este caso
- Cuando un plugin convierte IDs guardados en objetos (campos de relación, selectores de entradas, shortcodes que aceptan un ID), revisa con qué
post_statusconsulta:'any'sin uncurrent_user_can( 'read_post', $id )por resultado es la señal de alarma. - Si un parche añade una comprobación de permisos a algunas rutas (AJAX, REST, bloques), busca todas las demás llamadas a la misma función auxiliar: aquí quedaban el renderizado,
format_value()y el JSON-LD. - La comprobación de lectura debe vivir en la función que resuelve los datos, no en cada punto de entrada; así cualquier forma de guardar la referencia (formulario, editor, WP-CLI) queda cubierta.
- Al devolver un
WP_Posta contextos públicos, elimina campos internos comopost_passwordy respetapost_password_required()antes de exponer el cuerpo.