StreamSec Home  Forum Home 
 
Welcome, Guest.
Your IP: 216.73.216.150
2026-09-25 22:39:45 
 Announcements
 StreamSec Tools 4.1.1.354 - manual fix P01 The SFTP server follows a junction or symbolic link out of its root folder.
Bottom
 
Total posts: 1
 Author StreamSec Tools 4.1.1.354 - manual fix P01 The SFTP server follows a junction or symbolic link out of its root folder.
Henrick Wibell Hellström

2026-09-25 19:43:49
Registered user
WHAT GOES WRONG
The SFTP server subsystem confines every SFTP user to a root folder. It checks
a requested path by its name: '.' and '..' are resolved within the root, and
the full path that results must begin with the root's. Windows, however,
follows a junction or a symbolic link when a path is opened, and a check of
the name cannot see one. A junction or symbolic link anywhere inside the
served folder therefore takes SFTP users out of the root: they can list,
read, write, create and delete through it, with the rights of the server
process. The server refuses the SFTP request that creates links, so such a
link has to be placed by someone with local write access to the served
folder, or be there already.

WHAT THE FIX DOES
Before a request uses a path, the server asks Windows where the path really
leads, and refuses it unless that is inside the root. A name that does not
exist yet - a file or directory about to be created, a rename target - is
judged by its nearest existing parent folder, and a link whose target is
gone is refused. When a file is opened, the opened file is checked the same
way, so a link put in the way between the check and the open cannot
redirect the file's contents either. A root folder that is itself reached
through a junction keeps working.

WHO NEEDS IT
Applications that serve SFTP with the SFTP server subsystem of 4.1.1.354
(StreamSec.SFTP.ServerSubSystem).

APPLIES TO
StreamSec Tools 4.1.1.354, in StreamSec.SFTP.ServerSubSystem.pas, the
release in which the SFTP server first shipped. 4.1.2.355 and later confine
paths differently, and this fix does not apply to them; 4.1.2.355 to
4.1.3.359 have a fix of their own.

HOW TO APPLY - three edits in StreamSec.SFTP.ServerSubSystem.pas
1. In the implementation section, directly before the line
      constructor tSFTPJail.Create(const aPhysicalRoot: string);

   insert the routines of PART 1 below.
2. At the end of function tSFTPJail.MapToPhysical, find the lines
      if not (SameText(lFull,fRoot) or StartsText(lRootD,lFull)) then
         Exit;
       aPhysical := lFull;

   and insert the lines of PART 2 between "Exit;" and "aPhysical := lFull;".
3. In procedure tSFTPServerSubSystem.DoOpen, find the line
       lH.Stream := TFileStream.Create(lPhys,lMode);
   and insert the lines of PART 3 directly after it.
Then rebuild the packages, and the applications that serve SFTP. The fix is
pure Pascal; no assembler is involved.

-- PART 1 ----------------------------------------------------------------------
{ ------------------------------------------ where a path really leads -- }

{ The name checks in tSFTPJail.MapToPhysical cannot see a junction or a
  symbolic link: Windows follows them when the path is opened, so a link inside
  the root can lead out of it. These ask Windows where a path actually leads. }

const
  cFinalPathFlags = FILE_NAME_NORMALIZED or VOLUME_NAME_DOS;

function FinalPathOfHandle(aHandle: THandle; out aPath: string): Boolean;
var
  lNeed: DWORD;
  lBuf: string;
begin
  Result := False;
  aPath := '';
  lNeed := GetFinalPathNameByHandleW(aHandle,nil,0,cFinalPathFlags);
  if (lNeed = 0) or (lNeed > 40000) then
    Exit;
  SetLength(lBuf,lNeed + 1);
  lNeed := GetFinalPathNameByHandleW(aHandle,PWideChar(lBuf),lNeed + 1,
                                     cFinalPathFlags);
  if (lNeed = 0) or (lNeed > DWORD(Length(lBuf))) then
    Exit;
  SetLength(lBuf,lNeed);
  aPath := lBuf;
  Result := True;
end;

{ Opens aPath following every link, as the server's own open does, and returns
  where it leads; aError is the Windows error when it cannot be opened. }
function FinalPathOfName(const aPath: string; out aFinal: string;
                         out aError: DWORD): Boolean;
var
  lH: THandle;
  lPath: string;
begin
  Result := False;
  aFinal := '';
  lPath := aPath;
  if (Length(lPath) = 2) and (lPath[2] = ':') then
    lPath := lPath + '';                    // a drive root, not the drive's
                                             // current directory
  lH := CreateFileW(PWideChar(lPath),FILE_READ_ATTRIBUTES,
                    FILE_SHARE_READ or FILE_SHARE_WRITE or FILE_SHARE_DELETE,
                    nil,OPEN_EXISTING,FILE_FLAG_BACKUP_SEMANTICS,0);
  if lH = INVALID_HANDLE_VALUE then begin
    aError := GetLastError;
    Exit;
  end;
  aError := 0;
  try
    Result := FinalPathOfHandle(lH,aFinal);
  finally
    CloseHandle(lH);
  end;
end;

function InsideFinalRoot(const aRootFinal, aFinal: string): Boolean;
begin
  Result := SameText(aFinal,aRootFinal) or
            StartsText(IncludeTrailingPathDelimiter(aRootFinal),aFinal);
end;

{ True when aFull - already inside aRoot by name - also leads inside it on
  disk. A name that does not exist yet (a file or directory about to be
  created, a rename target) is judged by its nearest existing parent. A name
  that exists but cannot be followed - a link whose target is gone - is
  refused, and so is anything Windows will not open for another reason. }
function LeadsInsideRoot(const aRoot, aFull: string): Boolean;
var
  lRootFinal, lFinal, lProbe: string;
  lError: DWORD;
begin
  Result := False;
  if not FinalPathOfName(aRoot,lRootFinal,lError) then
    Exit;
  lProbe := aFull;
  while not FinalPathOfName(lProbe,lFinal,lError) do begin
    if ((lError <> ERROR_FILE_NOT_FOUND) and
        (lError <> ERROR_PATH_NOT_FOUND)) or
       (GetFileAttributesW(PWideChar(lProbe)) <> INVALID_FILE_ATTRIBUTES) or
       (Length(lProbe) <= Length(aRoot)) then
      Exit;
    lProbe := ExtractFileDir(lProbe);
  end;
  Result := InsideFinalRoot(lRootFinal,lFinal);
end;

{ The same question for a file already opened: where does its handle lead. }
function HandleInsideRoot(const aRoot: string; aHandle: THandle): Boolean;
var
  lRootFinal, lFinal: string;
  lError: DWORD;
begin
  Result := FinalPathOfName(aRoot,lRootFinal,lError) and
            FinalPathOfHandle(aHandle,lFinal) and
            InsideFinalRoot(lRootFinal,lFinal);
end;

-- PART 2 ----------------------------------------------------------------------

  // It must also lead inside the jail ON DISK: a junction or symbolic link in
  // the tree would take it out.
  if not LeadsInsideRoot(fRoot,lFull) then
    Exit;

-- PART 3 ----------------------------------------------------------------------

      // What was opened must lie inside the jail as well: the path was checked
      // a moment ago, by name, and a link could have been put in its way since.
      if not HandleInsideRoot(fJail.Root,lH.Stream.Handle) then
        RaiseLastOSError(ERROR_ACCESS_DENIED);

--------------------------------------------------------------------------------

TESTED
A test puts a junction inside the served folder that leads out of it, and one
whose target is gone, and serves a root folder that is itself reached through
a junction. Unpatched, 4.1.1.354 maps paths through both junctions - to read,
to create a file, to make a directory - to places outside the root. Patched,
it refuses every one of them and still maps every ordinary path, on Win32 and
Win64. The SFTP self-test, which runs our own SFTP server and client against
each other over SSH, passes unchanged with the fix.
Top

:: Written with and Powered by StreamSec Tools 4.1 ::
Copyright © 2000-2026 StreamSec Handelsbolag
Forum data layer and page templates derived from the RealThinClient SDK web-forum example, Copyright © 2004-2022 Teppi Technology (MIT)