You can malloc each dimension separately as a single contiguous block (so a 4D array would be just 4 mallocs).

Example for 2D
Code:
#include <stdio.h>
#include <stdlib.h>

int **allocate2Darray ( int rows, int cols ) {
  int **result;
  int r, *rp;  // initialise each row pointer
  
  result = malloc( rows * sizeof *result );
  result[0] = malloc( rows * cols * sizeof *result[0] );
  for ( r = 0, rp = result[0] ; r < rows ; r++, rp += cols ) {
    result[r] = rp;
  }
  return result;
}

void free2Darray ( int **array ) {
  free( array[0] );
  free( array );
}

#define MAX_R 5
#define MAX_C 16
int main(int argc, char **argv) {
  int **a = allocate2Darray( MAX_R, MAX_C );
  int r, c;
  for ( r = 0 ; r < MAX_R ; r++ ) {
    for ( c = 0 ; c < MAX_C ; c++ ) {
      a[r][c] = 0;
    }
  }
  printf( "The pitch between rows is %x bytes\n", MAX_C * sizeof(int) );
  for ( r = 0 ; r < MAX_R ; r++ ) {
    printf( "Row %d begins at %p\n", r, (void*)a[r] );
  }
  free2Darray( a );
  return 0;
}
3D would have a malloc for result[0][0] and so on.

It's quite nice in that it doesn't involve any pointer casting and do-it-yourself pointer arithmetic to make it work