CVE-2026-48855
Description
Erlang OTP's ssh_sftpd module leaks the absolute filesystem path of the SFTP root directory via the SSH_FXP_READLINK handler.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Erlang OTP's ssh_sftpd module leaks the absolute filesystem path of the SFTP root directory via the SSH_FXP_READLINK handler.
Vulnerability
An Exposure of Sensitive Information to an Unauthorized Actor vulnerability exists in the ssh_sftpd module of Erlang OTP. The SSH_FXP_READLINK handler incorrectly sends the raw result of file:read_link/2 to the client without applying the chroot_filename/2 function to strip the backend root prefix. This affects OTP versions from 17.0 before 29.0.2, 28.5.0.2, and 27.3.4.13, corresponding to ssh versions before 6.0.1, 5.5.2.1, and 5.2.11.8 [3].
Exploitation
An authenticated SFTP client can exploit this by creating a symbolic link within the chroot directory that points to the root directory (/). When the client then reads this symlink using SSH_FXP_READLINK, the server resolves the target to the absolute backend root path and returns it to the client, instead of the expected chrooted value [4].
Impact
Successful exploitation allows an attacker to discover the absolute filesystem path of the SFTP root directory and any symlink targets within it. This information disclosure could reveal host directory structures, mount point names, or usernames if the root is located under /home. However, this vulnerability alone does not grant access to file contents, credentials, or paths outside the configured root directory [3, 4].
Mitigation
This issue is fixed in Erlang OTP versions 29.0.2, 28.5.0.2, and 27.3.4.13, and corresponding ssh versions 6.0.1, 5.5.2.1, and 5.2.11.8 [3]. Workarounds include using OS-level chroot to isolate the SFTP server process, ensuring the SFTP port is not reachable from untrusted machines, and avoiding sensitive information in the configured root directory path [4].
AI Insight generated on Jun 10, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
3Patches
18f4224a0d267Fix absolute path leak from SSH_FXP_READLINK
2 files changed · +114 −20
lib/ssh/src/ssh_sftpd.erl+4 −2 modified@@ -371,8 +371,10 @@ handle_op(?SSH_FXP_READLINK, ReqId, <<?UINT32(PLen), RelPath:PLen/binary>>, {Res, FS1} = FileMod:read_link(AbsPath, FS0), case Res of {ok, NewPath} -> - ssh_xfer:xf_send_name(State#state.xf, ReqId, NewPath, - #ssh_xfer_attr{type=regular}); + AbsTarget = filename:absname(NewPath, filename:dirname(AbsPath)), + ChrootedPath = chroot_filename(canonicalize_filename(AbsTarget), State), + ssh_xfer:xf_send_name(State#state.xf, ReqId, ChrootedPath, + #ssh_xfer_attr{type=regular}); {error, Error} -> ssh_xfer:xf_send_status(State#state.xf, ReqId, ssh_xfer:encode_erlang_status(Error))
lib/ssh/test/ssh_sftpd_SUITE.erl+110 −18 modified@@ -36,6 +36,7 @@ -export([ access_outside_root/1, links/1, + links_root/1, mk_rm_dir/1, open_close_dir/1, open_close_file/1, @@ -95,6 +96,7 @@ all() -> retrieve_attributes, set_attributes, links, + links_root, ver3_rename, ver3_open_flags, relpath, @@ -179,6 +181,14 @@ init_per_testcase(TestCase, Config) -> SubSystems = [ssh_sftpd:subsystem_spec([{cwd, PrivDir}, {sftpd_vsn, 6}])], ssh:daemon(0, [{subsystems, SubSystems}|Options]); + links -> + SubSystems = [ssh_sftpd:subsystem_spec([{cwd, PrivDir}])], + ssh:daemon(0, [{subsystems, SubSystems}|Options]); + links_root -> + RootDir = filename:join(PrivDir, links_root), + ok = file:make_dir(RootDir), + SubSystems = [ssh_sftpd:subsystem_spec([{root, RootDir}, {cwd, RootDir}])], + ssh:daemon(0, [{subsystems, SubSystems}|Options]); _ -> SubSystems = [ssh_sftpd:subsystem_spec([])], ssh:daemon(0, [{subsystems, SubSystems}|Options]) @@ -468,28 +478,108 @@ real_path(Config) when is_list(Config) -> %%-------------------------------------------------------------------- links(Config) when is_list(Config) -> case os:type() of - {win32, _} -> - {skip, "Links are not fully supported by windows"}; - _ -> - ReqId = 0, - {Cm, Channel} = proplists:get_value(sftp, Config), - PrivDir = proplists:get_value(priv_dir, Config), - FileName = filename:join(PrivDir, "test.txt"), - LinkFileName = filename:join(PrivDir, "link_test.txt"), + {win32, _} -> + {skip, "Links are not fully supported by windows"}; + _ -> + Sftp = proplists:get_value(sftp, Config), + PrivDir = string:trim(proplists:get_value(priv_dir, Config), trailing, "/"), + LinkPath = filename:join(".", "link"), - {ok, <<?SSH_FXP_STATUS, ?UINT32(ReqId), - ?UINT32(?SSH_FX_OK), _/binary>>, _} = - create_link(LinkFileName, FileName, Cm, Channel, ReqId), + AbsBelowCwd = filename:join(PrivDir, "file"), + links_helper(Sftp, PrivDir, 0, LinkPath, AbsBelowCwd, AbsBelowCwd, AbsBelowCwd, AbsBelowCwd), - NewReqId = 1, - {ok, <<?SSH_FXP_NAME, ?UINT32(NewReqId), ?UINT32(_), ?UINT32(Len), - Path:Len/binary, _/binary>>, _} - = read_link(LinkFileName, Cm, Channel, NewReqId), + AbsAtCwd = PrivDir, + links_helper(Sftp, PrivDir, 1, LinkPath, AbsAtCwd, AbsAtCwd, AbsAtCwd, AbsAtCwd), + + AbsAboveCwd = filename:join([PrivDir, "..", "file"]), + AbsAboveCwdExpected = filename:join(filename:dirname(PrivDir), "file"), + links_helper(Sftp, PrivDir, 2, LinkPath, AbsAboveCwd, AbsAboveCwdExpected, AbsAboveCwd, AbsAboveCwdExpected), + + RelBelowCwd = filename:join(".", "file"), + RelBelowCwdExpected = AbsBelowCwd, + links_helper(Sftp, PrivDir, 3, LinkPath, RelBelowCwd, RelBelowCwdExpected, RelBelowCwd, RelBelowCwdExpected), + + RelAtCwd = ".", + RelAtCwdExpected = AbsAtCwd, + links_helper(Sftp, PrivDir, 4, LinkPath, RelAtCwd, RelAtCwdExpected, RelAtCwd, RelAtCwdExpected), + RelAboveCwd = filename:join("..", "file"), + RelAboveCwdExpected = AbsAboveCwdExpected, + links_helper(Sftp, PrivDir, 5, LinkPath, RelAboveCwd, RelAboveCwdExpected, RelAboveCwd, RelAboveCwdExpected) + end. - true = binary_to_list(Path) == FileName, +%%-------------------------------------------------------------------- +links_root(Config) when is_list(Config) -> + case os:type() of + {win32, _} -> + {skip, "Links are not fully supported by windows"}; + _ -> + Sftp = proplists:get_value(sftp, Config), + PrivDir = proplists:get_value(priv_dir, Config), + Root = filename:join(PrivDir, links_root), + LinkPath = filename:join("/", "link"), + + AbsBelowRoot = filename:join(Root, "file"), + AbsBelowRootClient = filename:join("/", "file"), + links_helper(Sftp, Root, 0, LinkPath, AbsBelowRoot, AbsBelowRoot, AbsBelowRootClient, AbsBelowRootClient), + + AbsAtRoot = Root, + AbsAtRootExpected = AbsAtRoot, + links_helper(Sftp, Root, 1, LinkPath, AbsAtRoot, AbsAtRootExpected, "/", "/"), + + AbsAboveRoot = filename:join(PrivDir, "file"), + AbsAboveRootExpected = AbsAtRootExpected, + AbsAboveRootClient = filename:join("/", ".."), + links_helper(Sftp, Root, 2, LinkPath, AbsAboveRoot, AbsAboveRootExpected, AbsAboveRootClient, "/"), + + RelBelowRoot = filename:join(".", "file"), + RelBelowRootExpected = filename:join(Root, "file"), + RelBelowRootClient = RelBelowRoot, + RelBelowRootClientExpected = AbsBelowRootClient, + links_helper(Sftp, Root, 3, LinkPath, RelBelowRoot, RelBelowRootExpected, RelBelowRootClient, RelBelowRootClientExpected), + + RelAtRoot = ".", + RelAtRootExpected = Root, + links_helper(Sftp, Root, 4, LinkPath, RelAtRoot, RelAtRootExpected, ".", "/"), + + RelAboveRoot = "../file", + RelAboveRootExpected = Root, + RelAboveRootClient = filename:join("..", "file"), + links_helper(Sftp, Root, 5, LinkPath, RelAboveRoot, RelAboveRootExpected, RelAboveRootClient, "/") + end. - ct:log("Path: ~p~n", [binary_to_list(Path)]) +links_helper({Cm, Channel}, Root, ReqId0, LinkPath, RawTarget, RawExpected, ClientTarget, ClientExpected) -> + ?CT_LOG("RawTarget: ~p, RawExpected: ~p~nClientTarget: ~p, ClientExpected: ~p~n", + [RawTarget, RawExpected, ClientTarget, ClientExpected]), + + ReqId1 = ReqId0 * 3, + LinkLocation = filename:join(Root, "link"), + ok = file:make_symlink(RawTarget, LinkLocation), + try + {ok, <<?SSH_FXP_NAME, ?UINT32(ReqId1), ?UINT32(_), + ?UINT32(Len1), ClientActualB1:Len1/binary, _/binary>>, _} = + read_link(LinkPath, Cm, Channel, ReqId1), + ClientActual1 = binary_to_list(ClientActualB1), + ClientExpected = ClientActual1, + + ok = file:delete(LinkLocation), + + ReqId2 = ReqId1 + 1, + {ok, <<?SSH_FXP_STATUS, ?UINT32(ReqId2), + ?UINT32(?SSH_FX_OK), _/binary>>, _} = + create_link(LinkPath, ClientTarget, Cm, Channel, ReqId2), + + {ok, RawActual} = file:read_link(LinkLocation), + RawExpected = string:trim(RawActual, trailing, "/"), + + ReqId3 = ReqId2 + 1, + {ok, <<?SSH_FXP_NAME, ?UINT32(ReqId3), ?UINT32(_), + ?UINT32(Len3), ClientActualB2:Len3/binary, _/binary>>, _} = + read_link(LinkPath, Cm, Channel, ReqId3), + ClientActual2 = binary_to_list(ClientActualB2), + ClientExpected = ClientActual2 + after + file:delete(LinkLocation) end. %%-------------------------------------------------------------------- @@ -806,7 +896,9 @@ prep(Config) -> %% Initial config DataDir = proplists:get_value(data_dir, Config), FileName = filename:join(DataDir, "test.txt"), - file:copy(FileName, TestFile), + {ok, Data0} = file:read_file(FileName), + Data = ssh_test_lib:remove_comment(Data0), + ok = file:write_file(TestFile, string:chomp(Data)), Mode = 8#00400 bor 8#00200 bor 8#00040, % read & write owner, read group {ok, FileInfo} = file:read_file_info(TestFile), ok = file:write_file_info(TestFile,
Vulnerability mechanics
Root cause
"The SSH_FXP_READLINK handler in ssh_sftpd does not properly sanitize symlink targets, leading to an absolute path disclosure."
Attack vector
An authenticated SFTP client can create a symbolic link within the chroot directory. When the client then requests to read this symbolic link using the SSH_FXP_READLINK handler, the server resolves the link's target to its absolute path on the backend filesystem and returns it to the client. This bypasses the intended chroot isolation for symlink targets [ref_id=1].
Affected code
The vulnerability resides within the SSH_FXP_READLINK handler in the ssh_sftpd module, specifically in the file lib/ssh/src/ssh_sftpd.erl. The commit associated with the fix, 8f4224a0d2676b0653d2c71a889a956e8c2c62d6, indicates changes related to link handling within this module [ref_id=1].
What the fix does
The patch modifies the ssh_sftpd module to ensure that the target of a symbolic link is processed correctly when read via SSH_FXP_READLINK. Specifically, it now calls chroot_filename/2 to strip the backend root prefix from the resolved symlink target before sending it to the client, preventing the disclosure of absolute paths [patch_id=5502042].
Preconditions
- authThe attacker must be an authenticated SFTP client.
- inputThe attacker must be able to create a symbolic link within the SFTP chroot directory.
Generated on Jun 10, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5News mentions
1- Erlang OTP: Seven Vulnerabilities Disclosed, Including High-Severity FlawsVypr Intelligence · Jun 10, 2026