Fix memory leak in HTTP server mode

When a client stopped the PCAP download, the isCloseConnection did not
detect the close. As a result, the PCAP dump would still be enqueued to
the ChunkedInputStream, causing the memory allocation to raise and never
be free.

Since NanoHTTPD does not seem to provide a way to detect connection close
and since it is not actively maintained, the HTTP server has been
replaced with an ad-hoc implementation which provides the minimal
features PCAPdroid needs to export the PCAP over HTTP.
This commit is contained in:
emanuele-f 2022-02-01 17:40:23 +01:00
parent e6906f3603
commit 18ffc2c374
3 changed files with 296 additions and 203 deletions

View File

@ -80,6 +80,5 @@ dependencies {
// Third-party
implementation 'cat.ereza:customactivityoncrash:2.3.0'
implementation 'org.nanohttpd:nanohttpd:2.3.1'
implementation 'com.github.KaKaVip:Android-Flag-Kit:v0.1'
}

View File

@ -1,137 +0,0 @@
/*
* This file is part of PCAPdroid.
*
* PCAPdroid is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PCAPdroid is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with PCAPdroid. If not, see <http://www.gnu.org/licenses/>.
*
* Copyright 2020-21 - Emanuele Faranda
*/
package com.emanuelef.remote_capture.pcap_dump;
import com.emanuelef.remote_capture.CaptureService;
import com.emanuelef.remote_capture.Utils;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/* Provides an input stream to read data from bytes chunks produced
asynchronously via produceData(). bytes[] chunks are used instead of a
single bytes[] in order to avoid excessive data copies.
*/
public class ChunkedInputStream extends InputStream {
final Lock mLock = new ReentrantLock();
final Condition newData = mLock.newCondition();
ArrayList<byte[]> mChunks = new ArrayList<byte[]>();
int mCurChunkIndex = 0;
boolean hasFinished = false;
public ChunkedInputStream() {
// Send the PCAP header as the first chunk
mChunks.add(CaptureService.getPcapHeader());
}
/* Mark the termination of stream */
public void stop() {
mLock.lock();
try {
hasFinished = true;
newData.signal();
} finally {
mLock.unlock();
}
}
/* Produce data to be read from the stream */
public void produceData(byte data[]) {
mLock.lock();
try {
if(hasFinished)
return;
mChunks.add(data);
newData.signal();
} finally {
mLock.unlock();
}
}
@Override
public int read(byte[] buf, int off, int maxlen) {
int out_size = 0;
if(maxlen <= 0)
return(0);
mLock.lock();
try {
/* Possibly wait for new data */
while((!hasFinished) && (mChunks.size() == 0))
newData.await();
if(mChunks.size() > 0) {
/* At least one byte will be returned here. Do not call await() below,
just return the available bytes to provide a more responsive transfer. */
while((mChunks.size() > 0) && (maxlen > 0)) {
byte[] chunk = mChunks.get(0);
if(off > 0) {
// skip bytes due to the offset
int toSkip = Math.min(off, chunk.length - mCurChunkIndex);
off -= toSkip;
mCurChunkIndex += toSkip;
}
if (mCurChunkIndex < chunk.length) {
int copy_length = Math.min(maxlen, chunk.length - mCurChunkIndex);
System.arraycopy(chunk, mCurChunkIndex, buf, out_size, copy_length);
out_size += copy_length;
mCurChunkIndex += copy_length;
maxlen -= copy_length;
}
if (mCurChunkIndex >= chunk.length) {
// next chunk
mChunks.remove(0);
mCurChunkIndex = 0;
}
}
return(out_size);
}
/* Should be reached when hasFinished is set */
return(-1);
} catch (InterruptedException e) {
return(-1);
} finally {
mLock.unlock();
}
}
@Override
public int read() {
byte[] buf = new byte[1];
int rv = read(buf, 0, 1);
if(rv == -1)
return(-1);
else
return(buf[0]);
}
}

View File

@ -14,96 +14,326 @@
* You should have received a copy of the GNU General Public License
* along with PCAPdroid. If not, see <http://www.gnu.org/licenses/>.
*
* Copyright 2020-21 - Emanuele Faranda
* Copyright 2020-22 - Emanuele Faranda
*/
package com.emanuelef.remote_capture.pcap_dump;
import android.content.Context;
import android.util.Log;
import com.emanuelef.remote_capture.R;
import com.emanuelef.remote_capture.CaptureService;
import com.emanuelef.remote_capture.Utils;
import com.emanuelef.remote_capture.interfaces.PcapDumper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.Closeable;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.StringTokenizer;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import fi.iki.elonen.NanoHTTPD;
import fi.iki.elonen.NanoHTTPD.Response.Status;
public class HTTPServer extends NanoHTTPD implements PcapDumper {
/*
* A simple HTTP server which allows clients to download the PCAP dump over HTTP.
*/
public class HTTPServer implements PcapDumper, Runnable {
private static final String TAG = "HTTPServer";
private static final String PCAP_MIME = "application/vnd.tcpdump.pcap";
private boolean mAcceptConnections = false;
private int mPort;
public static final int MAX_CLIENTS = 8;
private ServerSocket mSocket;
private boolean mRunning;
private Thread mThread;
private final int mPort;
private final Context mContext;
/* NOTE: access to mActiveResponses must be synchronized */
private final ArrayList<Response> mActiveResponses = new ArrayList<>();
// Shared state, must be synchronized
private final ArrayList<ClientHandler> mClients = new ArrayList<>();
public HTTPServer(Context context, int port) {
super(port);
mPort = port;
mContext = context;
}
private Response redirectToPcap() {
String fname = Utils.getUniquePcapFileName(mContext);
Response r = newFixedLengthResponse(Status.TEMPORARY_REDIRECT, MIME_HTML, "");
r.addHeader("Location", "/" + fname);
return(r);
}
/* Creates a new Response and add it to the active responses.
* NOTE: socket protect not needed for inbound connections. */
private synchronized Response newPcapStream() {
/* NOTE: response length is unknown */
Response res = newChunkedResponse(Status.OK, PCAP_MIME, new ChunkedInputStream());
mActiveResponses.add(res);
return res;
}
@Override
public Response serve(IHTTPSession session) {
if(!mAcceptConnections)
return newFixedLengthResponse(Status.FORBIDDEN, MIME_PLAINTEXT,
mContext.getString(R.string.start_capture_first));
if(session.getUri().endsWith("/")) {
/* Use a redirect to provide a file name */
return redirectToPcap();
private static class ChunkedOutputStream extends FilterOutputStream {
public ChunkedOutputStream(OutputStream out) throws IOException {
super(out);
}
return newPcapStream();
@Override
public void write(byte[] data) throws IOException {
// Chunked transfer coding
// https://datatracker.ietf.org/doc/html/rfc2616#section-3.6.1
out.write(String.format("%x\r\n", data.length).getBytes());
out.write(data);
out.write("\r\n".getBytes());
out.flush();
}
public void finish() throws IOException {
// Chunked transfer termination
out.write("0\r\n\r\n".getBytes());
}
}
/* Handles a single HTTP client. The normal workflow is:
* 1. if isReadyForData then sendChunk
* 2. if isClosed then remove this client
*
* No need for synchronization because sendChunk is only called when the runnable has terminated
* (see isReadyForData).
*/
private static class ClientHandler implements Runnable {
static final int INPUT_BUFSIZE = 1024;
Socket mSocket;
final InputStream mInputStream;
final OutputStream mOutputStream;
final String mFname;
ChunkedOutputStream mChunkedOutputStream;
boolean mHasError;
boolean mReadyForData;
boolean mHeaderSent;
boolean mIsClosed;
public ClientHandler(Socket socket, String fname) throws IOException {
mSocket = socket;
mFname = fname;
mInputStream = mSocket.getInputStream();
mOutputStream = mSocket.getOutputStream();
}
/* Detects and returns the end of the HTTP request headers */
private int getEndOfRequestHeaders(byte[] buf) {
for(int i = 0; i < (buf.length - 4); i++) {
if((buf[i] == '\r') && (buf[i+1] == '\n') && (buf[i+2] == '\r') && (buf[i+3] == '\n'))
return i+4;
}
return 0;
}
private void close(String error) {
if(error != null) {
Log.i(TAG, "Client error: " + error);
mHasError = true;
} else if (mReadyForData) {
try {
// Terminate the chunked stream
mChunkedOutputStream.finish();
} catch (IOException ignored) {}
}
checkedClose(mChunkedOutputStream);
checkedClose(mOutputStream);
checkedClose(mInputStream);
checkedClose(mSocket);
mIsClosed = true;
}
public void stop() {
// if active, will trigger a IOException
checkedClose(mSocket);
}
@Override
public void run() {
byte[] buf = new byte[INPUT_BUFSIZE];
int sofar = 0;
int req_size = 0;
try {
while(req_size <= 0) {
sofar += mInputStream.read(buf, sofar, buf.length - sofar);
req_size = getEndOfRequestHeaders(buf);
}
Log.d(TAG, "Request headers end at " + req_size);
//Log.d(TAG, "Req: " + new String(buf, 0, req_size, StandardCharsets.UTF_8));
try(BufferedReader reader = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(buf, 0, req_size)))) {
String line = reader.readLine();
if(line == null) {
close("Bad request");
return;
}
StringTokenizer tk = new StringTokenizer(line);
String method = tk.nextToken();
String url = tk.nextToken();
if(!method.equals("GET")) {
close("Bad request method");
return;
}
if(url.equals("/")) {
redirectToPcap();
close(null);
} else {
Log.d(TAG, "URL: " + url);
mOutputStream.write(("HTTP/1.1 200 OK\r\n" +
"Content-Type: " + PCAP_MIME + "\r\n" +
//"Content-Encoding: gzip\r\n" + // TODO?
"Connection: close\r\n" +
"Transfer-Encoding: chunked\r\n" +
"\r\n"
).getBytes());
mOutputStream.flush();
Log.d(TAG, "Ready for data");
mChunkedOutputStream = new ChunkedOutputStream(mOutputStream);
mReadyForData = true;
}
}
} catch (IOException | NoSuchElementException e) {
close(e.getLocalizedMessage());
}
}
/* Sends a 302 redirect to allow saving the PCAP file with a specific name */
private void redirectToPcap() throws IOException {
Log.d(TAG, "Redirecting to PCAP: " + mFname);
mOutputStream.write(("HTTP/1.1 302 Found\r\n" +
"Location: /" + mFname + "\r\n" +
"\r\n"
).getBytes());
}
// Returns true if the client socket is closed
public boolean isClosed() {
return mIsClosed;
}
public boolean isReadyForData() {
return mReadyForData;
}
// Send a chunk of data
public void sendChunk(byte []data) {
try {
if(!mHeaderSent) {
mChunkedOutputStream.write(CaptureService.getPcapHeader());
mHeaderSent = true;
}
//Log.d(TAG, "+CHUNK [" + data.length + "]");
mChunkedOutputStream.write(data);
} catch (IOException e) {
close(e.getLocalizedMessage());
}
}
}
@Override
public void startDumper() throws IOException {
mAcceptConnections = true;
start();
mSocket = new ServerSocket();
mSocket.setReuseAddress(true);
mSocket.bind(new InetSocketAddress(mPort));
mRunning = true;
mThread = new Thread(this);
mThread.start();
}
@Override
public void run() {
// NOTE: threads only handle the initial client communication.
// After isReadyForData, clients are handled in dumpData.
ExecutorService pool = Executors.newFixedThreadPool(MAX_CLIENTS);
while(mRunning) {
try {
Socket client = mSocket.accept();
synchronized(this) {
if(mClients.size() >= MAX_CLIENTS) {
Log.w(TAG, "Clients limit reached");
checkedClose(client);
continue;
}
}
Log.i(TAG, "New client: " + client.getInetAddress().getHostAddress() + ":" + client.getPort());
ClientHandler handler = new ClientHandler(client, Utils.getUniquePcapFileName(mContext));
try {
// will fail if pool is full
pool.submit(handler);
synchronized(this) {
mClients.add(handler);
}
} catch (RejectedExecutionException e) {
Log.w(TAG, e.getLocalizedMessage());
checkedClose(client);
}
} catch (IOException e) {
if(!mRunning)
Log.d(TAG, "Got termination request");
else
Log.d(TAG, e.getLocalizedMessage());
}
}
checkedClose(mSocket);
// Terminate the clients
pool.shutdown();
synchronized(this) {
for(ClientHandler client: mClients)
client.close(null);
mClients.clear();
}
// Wait clients termination
while(true) {
try {
if(pool.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS))
break;
} catch (InterruptedException ignored) {}
}
}
private static void checkedClose(Closeable socket) {
if(socket == null)
return;
try {
socket.close();
} catch (IOException e) {
Log.d(TAG, e.getLocalizedMessage());
}
}
@Override
public void stopDumper() throws IOException {
synchronized (this) {
for (int i = mActiveResponses.size() - 1; i >= 0; i--) {
Response res = mActiveResponses.get(i);
mRunning = false;
if (res.isCloseConnection()) {
/* Cleanup closed connections */
mActiveResponses.remove(i);
continue;
}
// Generate a socket exception
mSocket.close();
((ChunkedInputStream) res.getData()).stop();
while((mThread != null) && (mThread.isAlive())) {
try {
Log.d(TAG, "Joining HTTP thread...");
mThread.join();
} catch (InterruptedException e) {
Log.e(TAG, "Joining HTTP thread failed");
}
mActiveResponses.clear();
mAcceptConnections = false;
}
stop();
}
@Override
@ -113,18 +343,19 @@ public class HTTPServer extends NanoHTTPD implements PcapDumper {
@Override
public void dumpData(byte[] data) throws IOException {
synchronized (this) {
/* Dispatch PCAP data to the active connections */
for (int i = mActiveResponses.size() - 1; i >= 0; i--) {
Response res = mActiveResponses.get(i);
synchronized(this) {
Iterator<ClientHandler> it = mClients.iterator();
if (res.isCloseConnection()) {
/* Cleanup closed connections */
mActiveResponses.remove(i);
continue;
while(it.hasNext()) {
ClientHandler client = it.next();
if(client.isReadyForData())
client.sendChunk(data);
if(client.isClosed()) {
it.remove();
Log.d(TAG, "Client closed, active clients: " + mClients.size());
}
((ChunkedInputStream) res.getData()).produceData(data);
}
}
}