Reports
ALTA[EXPOSICIÓN DE DATOS]#4152553

Secure Custom Fields (WordPress) entregaba sin login entradas privadas, borradores y contraseñas de posts protegidos

Los campos de referencia de Secure Custom Fields resolvían IDs de entradas con acf_get_posts(), que no comprueba permisos de lectura: un visitante anónimo podía leer posts privados, borradores y la contraseña en claro de los protegidos.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

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 incluye draft, pending y private;
  • llama a get_posts() (línea 1211) sin comprobar current_user_can( 'read_post' ), ni is_post_publicly_viewable(), ni post_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.

  1. 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 muestre acf_form() con un campo Relationship (field_poc_def_rel, post_type: post,page) asociado a otra página pública de destino.
  2. Como visitante sin sesión (usuario 0) y sin cookies, comprobar la línea base: la entrada privada da 404, la API REST 401 rest_forbidden, el post protegido muestra el formulario de contraseña y current_user_can( 'read_post', 809 ) es false.
  3. 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
  1. 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"].

  1. Volver a pedir la página. El campo muestra el título de la entrada privada, mientras su enlace permanente sigue dando 404 al mismo visitante:
<span …>Private: POC-DEF PRIVATE TITLE (private)</span>
  1. Como usuario 0, get_field( 'poc_def_rel', <página de destino> ) devuelve un WP_Post con post_status = "private", el post_content completo, post_excerpt, post_author y las fechas.
  2. Repetir enviando 811: la misma llamada devuelve post_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).

  1. 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 el posts_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_status consulta: 'any' sin un current_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_Post a contextos públicos, elimina campos internos como post_password y respeta post_password_required() antes de exponer el cuerpo.