You can use the QR decomposition with column pivoting (see e.g. "The Behavior of the QR-Factorization Algorithm with Column Pivoting" by Engler (1997)). As described in that paper, the pivots give an ordering of the columns by "most linearly independent". Assuming we've computed the rank of the matrix already (which is a fair assumption since in general we'd need to do this to know it's low rank in the first place) we can then take the first $\text{rank}(X)$ pivots and should get a full rank matrix.
Here's an example.
set.seed(1)
n <- 50
inputs <- matrix(rnorm(n*3), n, 3)
x <- cbind(
inputs[,1], inputs[,2], inputs[,1] + inputs[,2],
inputs[,3], -.25 * inputs[,3]
)
print(Matrix::rankMatrix(x)) # 5 columns but rank 3
cor(x) # only detects the columns 4,5 collinearity, not 1,2,3
svd(x)$d # two singular values are numerically zero as expected
qr.x <- qr(x)
print(qr.x$pivot)
rank.x <- Matrix::rankMatrix(x)
print(Matrix::rankMatrix(x[,qr.x$pivot[1:rank.x]])) # full rank
Another comment on issues with just using pairwise correlation is that two columns having a perfect correlation doesn't even guarantee the matrix is low rank. As an example:
set.seed(1)
x <- rnorm(n)
x <- cbind(x, x + 1)
print(Matrix::rankMatrix(x))
cor(x)
These two columns are perfectly correlated, but since the constant vector is not in their span, it doesn't actually affect the rank. If there also was an intercept column then this matrix would indeed be rank $2$ (almost surely).