Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Server allows path traversal #3

Closed
tibordp opened this issue Jun 12, 2022 · 13 comments
Closed

Server allows path traversal #3

tibordp opened this issue Jun 12, 2022 · 13 comments

Comments

@tibordp
Copy link

tibordp commented Jun 12, 2022

Describe the bug
vrs serves files that are outside /var/www/static by using .. in path. This allows potentially sensistive files to be read from the server, for example /etc/passwd

To Reproduce

> sudo ./setup.sh
> cargo build
> sudo ./target/debug/vrs &
> echo "GET /../../../etc/passwd HTTP/1.1" | nc localhost 80
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1322

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
vscode:x:1000:1000::/home/vscode:/bin/bash
systemd-timesync:x:101:103:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
systemd-network:x:102:104:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-resolve:x:103:105:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
messagebus:x:104:106::/nonexistent:/usr/sbin/nologin
sshd:x:105:65534::/run/sshd:/usr/sbin/nologin

Expected behavior
404 or 400 error

@alphabitserial
Copy link

See this example code from Axum for one method of preventing path traversal attacks.

@PeterPierinakos
Copy link
Owner

Dang I didn't think of that. I'll work on a fix now.

@PeterPierinakos
Copy link
Owner

PeterPierinakos commented Jun 13, 2022

EDIT: Deprecated solution, look at the comment below.

@alphabitserial it seems like I have found a simpler fix although it seems a little junky.

tl;dr: potential fix

Why does this work?

HTML files end with a dot at the end. As a result, if the buffer's requested URI contains more than 1 dot, that means they are trying to use an incorrect path. BTW, I tried exploiting it by using the absolute path and it seems not to work.

If you guys see any problem or do not like this solution, I will try to implement @alphabitserial's solution. Otherwise, I will merge with main.

@PeterPierinakos
Copy link
Owner

PeterPierinakos commented Jun 13, 2022

Due to some file formats using more than one dot, I have come to the conclusion that @alphabitserial solution is the optimal one.

Final fix

I have put both of you in the credits. If you see any problem with this final fix, please report it so that I can fix it before merging with the main branch.

@PeterPierinakos
Copy link
Owner

Seems like there are no flaws with this solution. The fix will be merged to the main branch soon. Thanks once again for reporting this vulnerability.

@mcronce
Copy link

mcronce commented Jun 13, 2022

I just found this from a Reddit link, and obviously might be misreading, but doesn't this solution still allow (e.g.) a path like a/../../b to traverse above the configured root?

This is a bit more heavyweight, but it's code I've written elsewhere adapted to std::path and should be fully robust, if suboptimal:

// Could return an Option<Cow<'_, Path>> if you want to avoid an allocation in cases where the path is already normalized
// Could also just return a bool if you only want this to be a check for validity, which seems good for your use case
fn normalize_path(path: &Path) -> Option<PathBuf> {
    let mut result = PathBuf::new();
    let mut components = path.components();
    let first_component = components.next();
    match first_component {
        Some(Component::Prefix(p)) => result.push(Component::Prefix(p)),
        Some(Component::RootDir) => result.push(Component::RootDir), // If you've already stripped the leading / from the requested path, this should also return None
        _ => return None
    };
    for component in components {
        match component {
            Component::Prefix(_) => return None, // Should be unreachable
            Component::RootDir => return None, // Should be unreachable
            Component::CurDir => if result.as_str().is_empty() {
                // If you've already stripped the leading / from the requested path, this should no-op
                result.push(Component::RootDir);
            },
            Component::ParentDir => if !result.pop() {
                return None;
            },
            Component::Normal(p) => result.push(p)
        {;
    }
    Some(result)
}

@PeterPierinakos
Copy link
Owner

@mcronce you are right. It is still exploitable. I'll test your solution which seems to be improved and push it to main.

@PeterPierinakos
Copy link
Owner

By the way, I had to make a few change to your code. At line 14, you tried to call the .as_str() function for a PathBuf type. There is no such function for PathBuf, I am assuming you meant to call .as_os_str() before checking whether it's empty :)

@PeterPierinakos
Copy link
Owner

Here is the final solution by the way (optimized it a little bit, still works)

pub fn path_is_valid(path: &Path) -> bool {
        let mut result = PathBuf::new();
        let components = path.components();

        for component in components {
            match component {
                Component::Prefix(_) => return false, // Should be unreachable
                Component::RootDir => return false, // Should be unreachable
                Component::CurDir => if result.as_os_str().is_empty() {
                    // If you've already stripped the leading / from the requested path, this should no-op
                    result.push(Component::RootDir);
                },
                Component::ParentDir => if !result.pop() {
                    return false;
                },
                Component::Normal(p) => result.push(p)
            };
        }
        true
    }

@mcronce
Copy link

mcronce commented Jun 14, 2022

Whoops, good call - yeah should have been .as_os_str(). I adapted this from code that uses camino and did not try to compile before posting it ;)

I'm glad you were able to make use of it. You can actually optimize further by eliminating the result PathBuf entirely in favor of just incrementing and decrementing an integer since you're only looking to get a bool out of it :)

Something like this:

pub fn path_is_valid(path: &Path) -> bool {
	let mut stack = 0;
	let components = path.components();

	for component in components {
		match component {
			Component::Prefix(_) => return false, // Should be unreachable
			Component::RootDir => return false, // Should be unreachable
			Component::CurDir => if stack == 0 {
				// If you've already stripped the leading / from the requested path, this should no-op
				stack += 1;
			},
			Component::ParentDir if stack == 0 => return false,
			Component::ParentDir => stack -= 1,
			Component::Normal(p) => stack += 1
		};
	}
	true
}

I translated it directly from your code, but that Component::CurDir arm does smell a little sus; based on what you posted it looks like you've already made the path non-absolute, which suggests that should just no-op, but I haven't read the code so don't just take my word for it ;) this is complex enough logic that if it were me I'd probably throw together a few tests to validate.

@mcronce
Copy link

mcronce commented Jun 14, 2022

If I'm right about the CurDir arm (which is a big if), this would probably be correct:

pub fn path_is_valid(path: &Path) -> bool {
	let mut stack = 0;
	let components = path.components();

	for component in components {
		stack += match component {
			Component::Prefix(_) => return false, // Should be unreachable
			Component::RootDir => return false, // Should be unreachable
			Component::CurDir => 0,
			Component::ParentDir if stack == 0 => return false,
			Component::ParentDir => -1,
			Component::Normal(p) => 1
		};
	}
	true
}

@PeterPierinakos
Copy link
Owner

I will probably create a future-improvements branch with minor changes like this to be merged with the main branch for the next release.

@PeterPierinakos
Copy link
Owner

Seems like there are no more complaints about path traversal exploits anymore, locking the conversation. Feel free to submit a new issue if a new path traversal vulnerability is found

Repository owner locked and limited conversation to collaborators Jun 16, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants