Reports
MEDIA[IDOR / BOLA]#3893632

Secure Custom Fields: fuga sin autenticación de títulos e IDs de entradas en borrador, privadas o pendientes

Los manejadores AJAX públicos de los campos post_object, relationship y page_link de Secure Custom Fields 6.9.2 consultaban entradas con post_status "any", y cualquier visitante anónimo podía listar y buscar contenido no publicado.

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

El plugin Secure Custom Fields (SCF) 6.9.2 para WordPress expone a usuarios sin sesión (mediante ganchos wp_ajax_nopriv_) las consultas AJAX que alimentan los selectores de tres tipos de campo: post_object, relationship y page_link. El problema es que esas consultas no filtran por estado de publicación ni comprueban permisos, así que devuelven también entradas en borrador, pendientes, privadas y programadas, además de tipos de contenido personalizados que no son públicos.

HackerOne lo clasificó como control de acceso inadecuado (Improper Access Control - Generic), con severidad media. El programa lo dio por resuelto y pagó recompensa, aunque el importe no es público.

Dónde está el fallo

El registro del manejador sin autenticación está en includes/fields/class-acf-field-post_object.php:

// línea 34
add_action('wp_ajax_nopriv_acf/fields/post_object/query', [$this,'ajax_query']);

ajax_query() (línea 66) llama a get_ajax_query() (línea 96). Allí solo se fija post_status si el propio campo lo tiene configurado, y por defecto no lo tiene:

// línea 148
if ( ! empty( $field['post_status'] ) ) { $args['post_status'] = ...; }

Las consultas acaban en acf_get_grouped_posts( $args ) (includes/api/api-helpers.php, línea 1285), que usa este valor por defecto:

// línea 1299
'post_status' => 'any',

Con 'any', sin el argumento perm y sin ninguna llamada a current_user_can(), WP_Query devuelve también las entradas con estados no públicos: borrador, pendiente, privada y programada. El mismo patrón se repite en class-acf-field-relationship.php y class-acf-field-page_link.php.

Por qué el nonce no protege

La única barrera es acf_verify_ajax() (api-helpers.php, línea 707), que exige un nonce por campo con el nombre acf_field_post_object_<field_key>. Pero cuando el campo se pinta en un formulario público con acf_form() (el uso habitual para que los visitantes envíen o editen entradas desde el front-end), ese nonce aparece en el HTML de la página. Y en WordPress los nonces de usuarios no identificados son iguales para todos los visitantes anónimos (uid 0 y token de sesión vacío), así que cualquiera puede copiarlo y reutilizarlo.

Pasos de reproducción

  1. Instala WordPress con Secure Custom Fields 6.9.2. Crea un grupo de campos asignado a las entradas con un campo post_object sin restricción de post_status (es el valor por defecto).
  2. Muestra ese campo en una página pública mediante un formulario de front-end: una plantilla o bloque que llame a acf_form_head() y a:
acf_form(array('post_id'=>'new_post','fields'=>array('<field_key>')))
  1. Crea contenido que no sea público: una entrada en borrador titulada SECRET-DRAFT y una entrada privada titulada PRIVATE-POST.
  2. Sin cookies ni credenciales, descarga la página del formulario y extrae el nonce del campo:
curl -s https://SITE/pubform/ | grep -o 'data-nonce="[a-f0-9]*"'

La salida tiene la forma data-nonce="[REDACTADO]".

  1. Llama al endpoint AJAX sin autenticación con ese nonce:
curl -s https://SITE/wp-admin/admin-ajax.php \
  --data 'action=acf/fields/post_object/query&field_key=<field_key>&nonce=[REDACTADO]&paged=1'

La respuesta (HTTP 200) incluye el borrador y la entrada privada junto a la pública:

{"results":[
   {"id":1,"text":"Hello world!"},
   {"id":32,"text":"PRIVATE-POST (private)"},
   {"id":31,"text":"SECRET-DRAFT (draft)"}
],"more":false,"limit":20}
  1. Para buscar algo en concreto, añade el parámetro s:
curl ... --data '...&nonce=[REDACTADO]&s=SECRET'
{"results":[{"id":31,"text":"SECRET-DRAFT (draft)"}],...}

El investigador indica que lo reprodujo de principio a fin con un nonce real extraído de la página, sin simular nada.

Impacto

Un atacante sin cuenta puede obtener los títulos y los IDs de entradas en borrador, pendientes, privadas y programadas, es decir, contenido que el sitio no quiere hacer público. Con el parámetro s puede además buscar entradas concretas por palabra clave. Si el campo no está limitado a un tipo de contenido, también quedan al descubierto los tipos personalizados no públicos. En la práctica esto filtra contenido editorial sin publicar, artículos bajo embargo y registros internos o privados.

El escenario descrito en el reporte es el de un sitio que muestra alguno de estos campos en un formulario público con acf_form(), que es donde el nonce queda expuesto.